Compare commits

..

1 Commits

Author SHA1 Message Date
3a1b3fbaca WIP 2025-04-08 18:46:54 +01:00
159 changed files with 1655 additions and 5919 deletions

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.20.4",
"version": "0.9.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4",
"@tiptap/extension-character-count": "^2.11.5",
"axios": "^1.8.4",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@ -34,7 +34,6 @@
"jotai": "^2.12.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.21",
"lowlight": "^3.2.0",
"mermaid": "^11.4.1",
@ -63,7 +62,7 @@
"@types/node": "22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
@ -76,6 +75,6 @@
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.3.2"
"vite": "^6.1.0"
}
}

View File

@ -351,37 +351,5 @@
"Created at: {{time}}": "Erstellt am: {{time}}",
"Edited by {{name}} {{time}}": "Bearbeitet von {{name}} {{time}}",
"Word count: {{wordCount}}": "Wortanzahl: {{wordCount}}",
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
"New update": "Neues Update",
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
"Delete member": "Mitglied löschen",
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
"Move": "Verschieben",
"Move page": "Seite verschieben",
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
"Table of contents": "Inhaltsverzeichnis",
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen.",
"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"
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}"
}

View File

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

View File

@ -94,7 +94,7 @@
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
"Join the workspace": "Unirse al espacio de trabajo",
"Language": "Idioma",
"Light": "Claro",
"Light": "Ligero",
"Link copied": "Enlace copiado",
"Login": "Iniciar sesión",
"Logout": "Cerrar sesión",
@ -351,37 +351,5 @@
"Created at: {{time}}": "Creado a: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
"Word count: {{wordCount}}": "Conteo de palabras: {{wordCount}}",
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
"New update": "Nueva actualización",
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
"Delete member": "Eliminar miembro",
"Member deleted successfully": "Miembro eliminado con éxito",
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
"Move": "Mover",
"Move page": "Mover página",
"Move page to a different space.": "Mover página a un espacio diferente.",
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
"Table of contents": "Índice de contenidos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos.",
"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"
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}"
}

View File

@ -21,7 +21,7 @@
"Can view": "Peut voir",
"Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.",
"Cancel": "Annuler",
"Change email": "Changer le courriel",
"Change email": "Changer l'email",
"Change password": "Changer le mot de passe",
"Change photo": "Changer la photo",
"Choose a role": "Choisir un rôle",
@ -351,37 +351,5 @@
"Created at: {{time}}": "Créé à : {{time}}",
"Edited by {{name}} {{time}}": "Modifié par {{name}} {{time}}",
"Word count: {{wordCount}}": "Nombre de mots : {{wordCount}}",
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
"New update": "Nouvelle mise à jour",
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
"Delete member": "Supprimer le membre",
"Member deleted successfully": "Membre supprimé avec succès",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
"Move": "Déplacer",
"Move page": "Déplacer la page",
"Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
"Table of contents": "",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
"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"
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}"
}

View File

@ -347,41 +347,9 @@
"Members added successfully": "Membri aggiunti con successo",
"Member removed successfully": "Membro rimosso con successo",
"Member role updated successfully": "Ruolo del membro aggiornato con successo",
"Created by: <b>{{creatorName}}</b>": "Creato da: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Creato il: {{time}}",
"Edited by {{name}} {{time}}": "Modificato da {{name}} il {{time}}",
"Word count: {{wordCount}}": "Conteggio parole: {{wordCount}}",
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
"New update": "Nuovo aggiornamento",
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
"Delete member": "Elimina membro",
"Member deleted successfully": "Membro eliminato con successo",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
"Move": "Sposta",
"Move page": "Sposta pagina",
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
"Table of contents": "Indice dei contenuti",
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.",
"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"
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
}

View File

@ -347,41 +347,9 @@
"Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバーが削除されました",
"Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "が作成しました:{{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバーが削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
"Move": "移動",
"Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します。",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出しH1、H2、H3を追加して目次を生成します。",
"Share": "共有",
"Public sharing": "公開共有",
"Shared by": "共有者",
"Shared at": "共有日時",
"Inherits public sharing from": "から公開共有を継承する",
"Share to web": "ウェブで共有",
"Shared to web": "ウェブに共有済み",
"Anyone with the link can view this page": "リンクを持っている人はこのページを閲覧できます",
"Make this page publicly accessible": "このページを公開します",
"Include sub-pages": "サブページを含む",
"Make sub-pages public too": "サブページも公開する",
"Allow search engines to index page": "検索エンジンにページのインデックス作成を許可する",
"Open page": "ページを開く",
"Page": "ページ",
"Delete public share link": "公開リンクを削除",
"Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました",
"Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました"
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
}

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",
"Select theme": "배경 선택",
"Send invitation": "초대 보내기",
"Invitation sent": "초대 발송 완료",
"Invitation sent": "Invitation sent",
"Settings": "설정",
"Setup workspace": "Workspace 설정",
"Sign In": "로그인",
@ -245,7 +245,7 @@
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
"Align center": "가운데 정렬",
"Justify": "정렬",
"Justify": "Justify",
"Merge cells": "셀 병합",
"Split cell": "셀 분할",
"Delete column": "열 삭제",
@ -341,47 +341,15 @@
"Names do not match": "이름이 일치하지 않습니다",
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}",
"Space created successfully": "공간 생성 완료",
"Space updated successfully": "공간이 성공적으로 업데이트되었습니다",
"Space deleted successfully": "스페이스 삭제 완료",
"Members added successfully": "회원 추가 완료",
"Member removed successfully": "멤버가 성공적으로 제거되었습니다",
"Member role updated successfully": "회원 역할이 성공적으로 업데이트되었습니다",
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
"Created at: {{time}}": "생성 날짜: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 편집함 {{time}}",
"Word count: {{wordCount}}": "단어 수: {{wordCount}}",
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
"New update": "새로운 업데이트",
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
"Delete member": "회원 삭제",
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Move": "이동",
"Move page": "페이지 이동",
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
"Table of contents": "목차",
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요.",
"Share": "공유",
"Public sharing": "공개 공유",
"Shared by": "공유자",
"Shared at": "공유 시간",
"Inherits public sharing from": "로부터 공개 공유를 상속함",
"Share to web": "웹에 공유",
"Shared to web": "웹에 공유됨",
"Anyone with the link can view this page": "링크가 있는 사람은 이 페이지를 볼 수 있습니다",
"Make this page publicly accessible": "이 페이지를 공개적으로 접근 가능하게 만들기",
"Include sub-pages": "하위 페이지 포함",
"Make sub-pages public too": "하위 페이지도 공개로 설정",
"Allow search engines to index page": "검색 엔진이 페이지를 색인할 수 있도록 허용",
"Open page": "페이지 열기",
"Page": "페이지",
"Delete public share link": "공유 링크 삭제",
"Delete share": "공유 삭제",
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다"
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
}

View File

@ -351,37 +351,5 @@
"Created at: {{time}}": "Aangemaakt op: {{time}}",
"Edited by {{name}} {{time}}": "Bewerkt door {{name}} {{time}}",
"Word count: {{wordCount}}": "Aantal woorden: {{wordCount}}",
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
"New update": "Nieuwe update",
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
"Delete member": "Verwijder lid",
"Member deleted successfully": "Lid succesvol verwijderd",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
"Move": "Verplaatsen",
"Move page": "Pagina verplaatsen",
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
"Table of contents": "Inhoudsopgave",
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren.",
"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"
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}"
}

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
"Select theme": "Selecionar tema",
"Send invitation": "Enviar convite",
"Invitation sent": "Convite enviado",
"Invitation sent": "Invitation sent",
"Settings": "Configurações",
"Setup workspace": "Configurar workspace",
"Sign In": "Entrar",
@ -245,7 +245,7 @@
"Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro",
"Justify": "Justificar",
"Justify": "Justify",
"Merge cells": "Mesclar células",
"Split cell": "Dividir célula",
"Delete column": "Excluir coluna",
@ -341,47 +341,15 @@
"Names do not match": "Os nomes não coincidem",
"Today, {{time}}": "Hoje, {{time}}",
"Yesterday, {{time}}": "Ontem, {{time}}",
"Space created successfully": "Espaço criado com sucesso",
"Space updated successfully": "Espaço atualizado com sucesso",
"Space deleted successfully": "Espaço excluído com sucesso",
"Members added successfully": "Membros adicionados com sucesso",
"Member removed successfully": "Membro removido com sucesso",
"Member role updated successfully": "Função do membro atualizada com sucesso",
"Created by: <b>{{creatorName}}</b>": "Criado por: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Criado em: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
"Word count: {{wordCount}}": "Contagem de palavras: {{wordCount}}",
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
"New update": "Nova atualização",
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
"Delete member": "Excluir membro",
"Member deleted successfully": "Membro removido com sucesso",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
"Move": "Mover",
"Move page": "Mover página",
"Move page to a different space.": "Mover página para um espaço diferente.",
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
"Table of contents": "Tabela de conteúdos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.",
"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"
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
}

View File

@ -13,11 +13,11 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
"Can edit": "Может изменять",
"Can manage workspace": "Может управлять рабочей областью",
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
"Can manage workspace": "Может управлять рабочим пространством",
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
"Can view": "Может просматривать",
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
"Cancel": "Отменить",
@ -34,7 +34,7 @@
"Create group": "Создать группу",
"Create page": "Создать страницу",
"Create space": "Создать пространство",
"Create workspace": "Создать рабочую область",
"Create workspace": "Создать рабочее пространство",
"Current password": "Текущий пароль",
"Dark": "Темная",
"Date": "Дата",
@ -92,7 +92,7 @@
"Invite new members": "Пригласить новых участников",
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
"Join the workspace": "Присоединиться к рабочей области",
"Join the workspace": "Присоединиться к рабочему пространству",
"Language": "Язык",
"Light": "Светлая",
"Link copied": "Ссылка скопирована",
@ -148,9 +148,9 @@
"Select role to assign to all invited members": "Выберите роль для всех приглашённых участников",
"Select theme": "Выберите тему",
"Send invitation": "Отправить приглашение",
"Invitation sent": "Приглашение отправлено",
"Invitation sent": "Invitation sent",
"Settings": "Настройки",
"Setup workspace": "Настроить рабочую область",
"Setup workspace": "Настроить рабочее пространство",
"Sign In": "Вход",
"Sign Up": "Регистрация",
"Slug": "Slug",
@ -177,9 +177,9 @@
"Untitled": "Без названия",
"Updated successfully": "Обновлено успешно",
"User": "Пользователь",
"Workspace": "Рабочая область",
"Workspace Name": "Имя рабочей области",
"Workspace settings": "Настройки рабочей области",
"Workspace": "Рабочее пространство",
"Workspace Name": "Имя рабочего пространства",
"Workspace settings": "Настройки рабочего пространства",
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
"Your Email": "Ваш адрес электронной почты",
"Your import is complete.": "Ваш импорт завершен.",
@ -217,9 +217,9 @@
"Revoke invitation": "Отозвать приглашение",
"Revoke": "Отозвать",
"Don't": "Нет",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочей области.",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
"Resend invitation": "Отправить приглашение повторно",
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
"Invite link": "Ссылка для приглашения",
"Copy": "Копировать",
"Copied": "Скопировано",
@ -245,7 +245,7 @@
"Align left": "По левому краю",
"Align right": "По правому краю",
"Align center": "По центру",
"Justify": "По ширине",
"Justify": "Justify",
"Merge cells": "Объединить ячейки",
"Split cell": "Разделить ячейку",
"Delete column": "Удалить столбец",
@ -331,57 +331,25 @@
"Insert math equation": "Вставить математическое выражение",
"Mermaid diagram": "Диаграмма Mermaid",
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
"Insert and design Drawio diagrams": "Вставить и рисовать диаграммы Draw.io",
"Insert and design Drawio diagrams": "Вставьте и редактируйте диаграммы Draw.io",
"Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"Draw and sketch excalidraw diagrams": "Создайте и рисуйте диаграммы Excalidraw",
"Multiple": "Несколько",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для выбора команд",
"Names do not match": "Названия не совпадают",
"Today, {{time}}": "Сегодня, {{time}}",
"Yesterday, {{time}}": "Вчера, {{time}}",
"Space created successfully": "Пространство успешно создано",
"Space updated successfully": "Пространство успешно обновлено",
"Space deleted successfully": "Пространство успешно удалено",
"Members added successfully": "Участники успешно добавлены",
"Member removed successfully": "Участник успешно удален",
"Member role updated successfully": "Роль участника успешно обновлена",
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Дата создания: {{time}}",
"Edited by {{name}} {{time}}": "Изменено {{name}} {{time}}",
"Word count: {{wordCount}}": "Количество слов: {{wordCount}}",
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
"New update": "Новое обновление",
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
"Delete member": "Удалить участника",
"Member deleted successfully": "Участник успешно удален",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
"Move": "Переместить",
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Table of contents": "Содержание",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
"Public sharing": "Общий доступ",
"Shared by": "Поделился",
"Shared at": "Поделился в",
"Inherits public sharing from": "Наследует общий доступ от",
"Share to web": "Поделиться в интернете",
"Shared to web": "Размещено в интернете",
"Anyone with the link can view this page": "Любой, у кого есть ссылка, может просмотреть эту страницу",
"Make this page publicly accessible": "Сделать эту страницу общедоступной",
"Include sub-pages": "Включить подстраницы",
"Make sub-pages public too": "Сделать подстраницы также общедоступными",
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
"Open page": "Открыть страницу",
"Page": "Страница",
"Delete public share link": "Удалить ссылку на общий доступ",
"Delete share": "Удалить общий доступ",
"Are you sure you want to delete this shared link?": "Вы уверены, что хотите удалить эту ссылку общего доступа?",
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
"Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден",
"Failed to share page": "Не удалось поделиться страницей"
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
}

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",
"Select theme": "选择主题",
"Send invitation": "发送邀请",
"Invitation sent": "邀请邮件已发送",
"Invitation sent": "Invitation sent",
"Settings": "设置",
"Setup workspace": "设置工作空间",
"Sign In": "登录",
@ -245,7 +245,7 @@
"Align left": "靠左对齐",
"Align right": "靠右对齐",
"Align center": "居中对齐",
"Justify": "两端对齐",
"Justify": "Justify",
"Merge cells": "合并单元格",
"Split cell": "分割单元格",
"Delete column": "删除整列",
@ -298,7 +298,7 @@
"Heading 2": "2 级标题",
"Heading 3": "3 级标题",
"To-do List": "代办列表",
"Bullet List": "无列表",
"Bullet List": "无列表",
"Numbered List": "有序列表",
"Blockquote": "引用块",
"Just start typing with plain text.": "只需开始键入纯文本",
@ -341,47 +341,15 @@
"Names do not match": "名称不匹配",
"Today, {{time}}": "今天,{{time}}",
"Yesterday, {{time}}": "昨天,{{time}}",
"Space created successfully": "空间创建成功",
"Space updated successfully": "空间更新成功",
"Space deleted successfully": "空间已成功删除",
"Members added successfully": "成员添加成功",
"Member removed successfully": "成员移除成功",
"Member role updated successfully": "成员角色更新成功",
"Created by: <b>{{creatorName}}</b>": "创建者:<b>{{creatorName}}</b>",
"Created at: {{time}}": "创建于:{{time}}",
"Edited by {{name}} {{time}}": "{{name}} 编辑于 {{time}}",
"Word count: {{wordCount}}": "字数:{{wordCount}}",
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
"New update": "新更新",
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
"Delete member": "删除成员",
"Member deleted successfully": "成员删除成功",
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
"Move": "移动",
"Move page": "移动页面",
"Move page to a different space.": "将页面移动到不同的空间。",
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
"Table of contents": "目录",
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题H1H2H3以生成目录。",
"Share": "分享",
"Public sharing": "公开分享",
"Shared by": "分享者",
"Shared at": "分享时间",
"Inherits public sharing from": "继承自的公开分享",
"Share to web": "分享到网页",
"Shared to web": "已分享到网页",
"Anyone with the link can view this page": "任何有链接的人都可以查看此页面",
"Make this page publicly accessible": "使此页面可公开访问",
"Include sub-pages": "包括子页面",
"Make sub-pages public too": "将子页面也设为公开",
"Allow search engines to index page": "允许搜索引擎索引页面",
"Open page": "打开页面",
"Page": "页面",
"Delete public share link": "删除公开分享链接",
"Delete share": "删除分享",
"Are you sure you want to delete this shared link?": "您确定要删除此分享链接吗?",
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
"Share deleted successfully": "分享已成功删除",
"Share not found": "未找到分享",
"Failed to share page": "页面分享失败"
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
}

View File

@ -26,16 +26,10 @@ import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from '@/pages/share/share-redirect.tsx';
import { useTrackOrigin } from "@/hooks/use-track-origin";
export default function App() {
const { t } = useTranslation();
useRedirectToCloudSelect();
useTrackOrigin();
return (
<>
@ -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 element={<Layout />}>
@ -90,7 +78,6 @@ export default function App() {
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}

View File

@ -65,12 +65,11 @@ export default function ExportModal({
yOffset="10vh"
xOffset={0}
mah={400}
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.Title fw={500}>Export {type}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>

View File

@ -21,7 +21,7 @@ export default function Paginate({
}
return (
<Group mt="md" justify="flex-end">
<Group mt="md">
<Button
variant="default"
size="compact-sm"

View File

@ -4,14 +4,10 @@ import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
let title: string;
let component: ReactNode;
@ -21,10 +17,6 @@ export default function Aside() {
component = <CommentList />;
title = "Comments";
break;
case "toc":
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
default:
component = null;
title = null;

View File

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

View File

@ -20,4 +20,4 @@ export const asideStateAtom = atom<AsideStateType>({
isAsideOpen: false,
});
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);

View File

@ -35,12 +35,6 @@ export default function AppVersion() {
position="middle-end"
style={{ cursor: "pointer" }}
disabled={!hasUpdate}
onClick={() => {
window.open(
"https://github.com/docmost/docmost/releases",
"_blank",
);
}}
>
<Text
size="sm"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,8 +19,8 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
queryKey: ["collab-token"],
queryFn: () => getCollabToken(),
staleTime: 20 * 60 * 60 * 1000, //20hrs
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
//refetchIntervalInBackground: true,
refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
refetchIntervalInBackground: true,
refetchOnMount: true,
//@ts-ignore
retry: (failureCount, error) => {

View File

@ -19,7 +19,8 @@
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
}
.ProseMirror :global(.ProseMirror){
.ProseMirror {
width: 100%;
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
@ -28,6 +29,7 @@
padding-right: 6px;
margin-top: 2px;
margin-bottom: 2px;
font-size: 14px;
overflow: hidden auto;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -247,7 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
searchTerms: ["collapsible", "block", "toggle", "details", "expand"],
icon: IconCaretRightFilled,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDetails().run(),
editor.chain().focus().deleteRange(range).toggleDetails().run(),
},
{
title: "Callout",

View File

@ -1,59 +0,0 @@
.headerPadding {
display: none;
top: calc(
var(--app-shell-header-offset, 0rem) + var(--app-shell-header-height, 0rem)
);
}
.link {
outline: none;
cursor: pointer;
display: block;
width: 100%;
text-align: start;
word-wrap: break-word;
background-color: transparent;
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
line-height: var(--mantine-line-height-sm);
padding: 6px;
border-top-right-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
border: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
@media (max-width: $mantine-breakpoint-sm) {
& {
border: none !important;
padding-left: 0px;
}
}
}
.linkActive {
font-weight: 500;
border-left-color: light-dark(
var(--mantine-color-grey-5),
var(--mantine-color-grey-3)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
&,
&:hover {
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-5)
) !important;
}
}
.leftBorder {
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}

View File

@ -1,183 +0,0 @@
import { NodePos, useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import React, { FC, useEffect, useRef, useState } from "react";
import classes from "./table-of-contents.module.css";
import clsx from "clsx";
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type TableOfContentsProps = {
editor: ReturnType<typeof useEditor>;
isShare?: boolean;
};
export type HeadingLink = {
label: string;
level: number;
element: HTMLElement;
position: number;
};
const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
(acc, item) => {
const label = item.node.textContent;
const level = Number(item.node.attrs.level);
if (label.length && level <= 3) {
acc.push({
label,
level,
element: item.element,
//@ts-ignore
position: item.resolvedPos.pos,
});
nodes.push(item.element);
}
return acc;
},
[],
);
return { links, nodes };
};
export const TableOfContents: FC<TableOfContentsProps> = (props) => {
const { t } = useTranslation();
const [links, setLinks] = useState<HeadingLink[]>([]);
const [headingDOMNodes, setHeadingDOMNodes] = useState<HTMLElement[]>([]);
const [activeElement, setActiveElement] = useState<HTMLElement | null>(null);
const headerPaddingRef = useRef<HTMLDivElement | null>(null);
const handleScrollToHeading = (position: number) => {
const { view } = props.editor;
const headerOffset = parseInt(
window.getComputedStyle(headerPaddingRef.current).getPropertyValue("top"),
);
const { node } = view.domAtPos(position);
const element = node as HTMLElement;
const scrollPosition =
element.getBoundingClientRect().top + window.scrollY - headerOffset;
window.scrollTo({
top: scrollPosition,
behavior: "smooth",
});
const tr = view.state.tr;
tr.setSelection(new TextSelection(tr.doc.resolve(position)));
view.dispatch(tr);
view.focus();
};
const handleUpdate = () => {
const result = recalculateLinks(props.editor?.$nodes("heading"));
setLinks(result.links);
setHeadingDOMNodes(result.nodes);
};
useEffect(() => {
props.editor?.on("update", handleUpdate);
return () => {
props.editor?.off("update", handleUpdate);
};
}, [props.editor]);
useEffect(
() => {
handleUpdate();
},
props.isShare ? [props.editor] : [],
);
useEffect(() => {
try {
const observeHandler = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveElement(entry.target as HTMLElement);
}
});
};
let headerOffset = 0;
if (headerPaddingRef.current) {
headerOffset = parseInt(
window
.getComputedStyle(headerPaddingRef.current)
.getPropertyValue("top"),
);
}
const observerOptions: IntersectionObserverInit = {
rootMargin: `-${headerOffset}px 0px -85% 0px`,
threshold: 0,
root: null,
};
const observer = new IntersectionObserver(
observeHandler,
observerOptions,
);
headingDOMNodes.forEach((heading) => {
observer.observe(heading);
});
return () => {
headingDOMNodes.forEach((heading) => {
observer.unobserve(heading);
});
};
} catch (err) {
console.log(err);
}
}, [headingDOMNodes, props.editor]);
if (!links.length) {
return (
<>
{!props.isShare && (
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
)}
{props.isShare && (
<Text size="sm" c="dimmed">
{t("No table of contents.")}
</Text>
)}
</>
);
}
return (
<>
{props.isShare && (
<Text mb="md" fw={500}>
{t("Table of contents")}
</Text>
)}
<div className={props.isShare ? classes.leftBorder : ""}>
{links.map((item, idx) => (
<Box<"button">
component="button"
onClick={() => handleScrollToHeading(item.position)}
key={idx}
className={clsx(classes.link, {
[classes.linkActive]: item.element === activeElement,
})}
style={{
paddingLeft: `calc(${item.level} * var(--mantine-spacing-md))`,
}}
>
{item.label}
</Box>
))}
</div>
<div ref={headerPaddingRef} className={classes.headerPadding} />
</>
);
};

View File

@ -228,4 +228,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -52,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps {
pageId: string;
@ -84,6 +83,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const collabRetryCount = useRef(0);
const slugId = extractPageSlugId(pageSlug);
const localProvider = useMemo(() => {
@ -105,11 +105,13 @@ export default function PageEditor({
connect: false,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
collabRetryCount.current = collabRetryCount.current + 1;
refetchCollabToken().then(() => {
collabRetryCount.current = 0;
});
if (collabRetryCount.current > 20) {
window.location.reload();
}
},
onStatus: (status) => {
@ -209,7 +211,6 @@ export default function PageEditor({
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
updatedAt: new Date(),
});
}
}, 3000);
@ -264,13 +265,19 @@ export default function PageEditor({
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
const reconnectTimeout = setTimeout(
() => {
remoteProvider.connect();
resetIdle();
},
collabRetryCount.current > 2 ? 3000 : 0,
);
setIsCollabReady(true);
return () => clearTimeout(reconnectTimeout);
}
}, [isIdle, documentState, remoteProvider]);
}, [isIdle, documentState, remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced;
@ -279,7 +286,7 @@ export default function PageEditor({
if (
!isCollabReady &&
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
remoteProvider.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
}

View File

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

View File

@ -53,22 +53,24 @@
padding: 4px;
word-break: break-word;
overflow-wrap: break-word;
[data-type="detailsContent"] {
display: none;
}
}
&[open] {
[data-type="detailsButton"] {
.ProseMirror-icon {
transform: rotateZ(90deg);
}
}
[data-type="detailsContainer"] {
[data-type="detailsContent"] {
display: block;
}
}
}
}
[data-type="details"] > [data-type="detailsContainer"] > [data-type="detailsContent"]{
display: none;
}
[data-type="details"][open] > [data-type="detailsContainer"] > [data-type="detailsContent"]{
display: block;
}
[data-type="details"][open] > [data-type="detailsButton"] {
align-items: start;
}
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
transform: rotateZ(90deg);
}
}
}

View File

@ -33,7 +33,7 @@ export async function getGroupMembers(
groupId: string,
params?: QueryParams,
): Promise<IPagination<IUser>> {
const req = await api.post("/groups/members", { groupId, ...params });
const req = await api.post("/groups/members", { groupId, params });
return req.data;
}

View File

@ -17,13 +17,6 @@ import {
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
interface Props {
pageId: string;
@ -43,11 +36,6 @@ function HistoryList({ pageId }: Props) {
const [mainEditorTitle] = useAtom(titleEditorAtom);
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const confirmModal = () =>
modals.openConfirmModal({
title: t("Please confirm your action"),
@ -115,26 +103,20 @@ function HistoryList({ pageId }: Props) {
))}
</ScrollArea>
{spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) ? null : (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
{t("Restore")}
</Button>
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
</Group>
</>
)}
<Divider />
<Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
{t("Restore")}
</Button>
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
</Group>
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -1,12 +1,10 @@
import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
import {
IconArrowRight,
IconArrowsHorizontal,
IconDots,
IconFileExport,
IconHistory,
IconLink,
IconList,
IconMessage,
IconPrinter,
IconTrash,
@ -33,15 +31,11 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
interface PageHeaderMenuProps {
readOnly?: boolean;
}
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
@ -49,7 +43,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<>
{yjsConnectionStatus === "disconnected" && (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
label="Real-time editor connection lost. Retrying..."
openDelay={250}
withArrow
>
@ -59,9 +53,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<Tooltip label="Comments" openDelay={250} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
@ -71,16 +63,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={() => toggleAside("toc")}
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<PageActionMenu readOnly={readOnly} />
</>
);
@ -101,12 +83,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const [tree] = useAtom(treeApiAtom);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const handleCopyLink = () => {
const pageUrl =
@ -170,15 +147,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
<Menu.Divider />
{!readOnly && (
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={openMovePageModal}
>
{t("Move")}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={openExportModal}
@ -213,7 +181,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
<Tooltip
label={t("Edited by {{name}} {{time}}", {
name: page.lastUpdatedBy.name,
time: pageUpdatedAt,
time: timeAgo(page.updatedAt),
})}
position="left-start"
>
@ -249,14 +217,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
open={exportOpened}
onClose={closeExportModal}
/>
<MovePageModal
pageId={page.id}
slugId={page.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
</>
);
}

View File

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

View File

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

View File

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

View File

@ -7,15 +7,13 @@ import {
usePageQuery,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import React, { useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
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 {
IconArrowRight,
IconChevronDown,
IconChevronRight,
IconCopy,
IconDotsVertical,
IconFileDescription,
IconFileExport,
@ -58,10 +56,6 @@ import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import MovePageModal from "../../components/move-page-modal.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx";
interface SpaceTreeProps {
spaceId: string;
@ -88,7 +82,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const isDataLoaded = useRef(false);
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
@ -112,7 +106,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
// and append root pages instead of resetting the entire tree
// which looses async loaded children too
setData(treeData);
setIsDataLoaded(true);
isDataLoaded.current = true;
setOpenTreeNodes({});
}
}
@ -120,7 +114,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
useEffect(() => {
const fetchData = async () => {
if (isDataLoaded && currentPage) {
if (isDataLoaded.current && currentPage) {
// check if pageId node is present in the tree
const node = dfs(treeApiRef.current?.root, currentPage.id);
if (node) {
@ -182,7 +176,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
};
fetchData();
}, [isDataLoaded, currentPage?.id]);
}, [isDataLoaded.current, currentPage?.id]);
useEffect(() => {
if (currentPage?.id) {
@ -234,14 +228,12 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const { t } = useTranslation();
const navigate = useNavigate();
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const { spaceSlug } = useParams();
const timerRef = useRef(null);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(() => {
@ -292,6 +284,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 updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
setTreeData(updatedTree);
@ -345,22 +342,13 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
}, 650);
}
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
return (
<>
<Box
<div
style={style}
className={clsx(classes.node, node.state)}
component={Link}
to={pageUrl}
// @ts-ignore
ref={dragHandle}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
onClick={handleClick}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
>
@ -381,7 +369,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
/>
</div>
<span className={classes.text}>{node.data.name || t("untitled")}</span>
<span className={classes.text}>{node.data.name || "untitled"}</span>
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} />
@ -394,7 +382,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
/>
)}
</div>
</Box>
</div>
</>
);
}
@ -446,14 +434,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const { openDeleteModal } = useDeletePageModal();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@ -506,29 +486,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
@ -545,21 +504,6 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
</Menu.Dropdown>
</Menu>
<MovePageModal
pageId={node.id}
slugId={node.data.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal
type="page"
id={node.id}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,195 +0,0 @@
import React from "react";
import {
ActionIcon,
Affix,
AppShell,
Button,
Group,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
import { useParams } from "react-router-dom";
import SharedTree from "@/features/share/components/shared-tree.tsx";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue } from "jotai";
import { useAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import {
mobileTableOfContentAsideAtom,
tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts";
import { IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
import { shareSearchSpotlight } from "@/features/search/constants";
const MemoizedSharedTree = React.memo(SharedTree);
export default function ShareShell({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [tocOpened] = useAtom(tableOfContentAsideAtom);
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
const toggleToc = useToggleToc(tableOfContentAsideAtom);
const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
return (
<AppShell
header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && {
navbar: {
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
},
})}
aside={{
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileTocOpened,
desktop: !tocOpened,
},
}}
padding="md"
>
<AppShell.Header>
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
<Group wrap="nowrap">
{data?.pageTree?.length > 1 && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
</Group>
{shareId && (
<Group visibleFrom="sm">
<SearchControl onClick={shareSearchSpotlight.open} />
</Group>
)}
<Group>
<>
{shareId && (
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
</Group>
)}
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={toggleTocMobile}
hiddenFrom="sm"
size="sm"
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={toggleToc}
visibleFrom="sm"
size="sm"
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
<ThemeToggle />
</Group>
</Group>
</AppShell.Header>
{data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md" className={classes.navbar}>
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
<AppShell.Main>
{children}
<Affix position={{ bottom: 20, right: 20 }}>
<Button
variant="default"
component="a"
target="_blank"
href="https://docmost.com?ref=public-share"
>
Powered by Docmost
</Button>
</Affix>
</AppShell.Main>
<AppShell.Aside
p="md"
withBorder={mobileTocOpened}
className={classes.aside}
>
<ScrollArea style={{ height: "80vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "50px" }}>
{readOnlyEditor && (
<TableOfContents isShare={true} editor={readOnlyEditor} />
)}
</div>
</ScrollArea>
</AppShell.Aside>
<ShareSearchSpotlight shareId={shareId} />
</AppShell>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,33 +6,21 @@ import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
interface SpaceSelectProps {
onChange: (value: ISpace) => void;
onChange: (value: string) => void;
value?: string;
label?: string;
width?: number;
opened?: boolean;
clearable?: boolean;
}
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm" wrap="nowrap">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<div>
<Text size="sm" lineClamp={1}>
{option.label}
</Text>
<Text size="sm" lineClamp={1}>{option.label}</Text>
</div>
</Group>
);
export function SpaceSelect({
onChange,
label,
value,
width,
opened,
clearable,
}: SpaceSelectProps) {
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
@ -54,8 +42,8 @@ export function SpaceSelect({
});
const filteredSpaceData = spaceData.filter(
(space) =>
!data.find((existingSpace) => existingSpace.value === space.value),
(user) =>
!data.find((existingUser) => existingUser.value === user.value),
);
setData((prevData) => [...prevData, ...filteredSpaceData]);
}
@ -71,18 +59,14 @@ export function SpaceSelect({
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable={clearable}
clearable
variant="filled"
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
// duct tape
onClick={(e) => e.stopPropagation()}
onChange={onChange}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal: false }}
dropdownOpened={opened}
comboboxProps={{ width: 300, withinPortal: false }}
dropdownOpened
/>
);
}

View File

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

View File

@ -55,9 +55,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={space => handleSelect(space.slug)}
width={300}
opened={true}
onChange={handleSelect}
/>
</Popover.Dropdown>
</Popover>

View File

@ -1,11 +1,4 @@
import {
Group,
Table,
Text,
Menu,
ActionIcon,
ScrollArea,
} from "@mantine/core";
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
@ -113,95 +106,93 @@ export default function SpaceMembersList({
return (
<>
<SearchInput onSearch={handleSearch} />
<ScrollArea h={400}>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Member")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Member")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
{member.type === "user" && (
<CustomAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
{!readOnly && (
<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={() =>
openRemoveModal(member.id, member.type)
}
>
{t("Remove space member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
{member.type === "user" && (
<CustomAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</ScrollArea>
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
{!readOnly && (
<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={() =>
openRemoveModal(member.id, member.type)
}
>
{t("Remove space member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate

View File

@ -70,6 +70,7 @@ function ChangeEmailForm() {
function handleSubmit(data: FormValues) {
setIsLoading(true);
console.log(data);
}
return (

View File

@ -1,7 +1,7 @@
import { Group, Text, Switch, MantineSize } from "@mantine/core";
import { useAtom } from "jotai/index";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import { Group, MantineSize, Switch, Text } from "@mantine/core";
import { useAtom } from "jotai/index";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@ -26,7 +26,6 @@ interface PageWidthToggleProps {
size?: MantineSize;
label?: string;
}
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
@ -51,4 +50,4 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
aria-label={t("Toggle full page width")}
/>
);
}
}

View File

@ -30,4 +30,4 @@ export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
};
}
}

View File

@ -1,66 +0,0 @@
import { Menu, ActionIcon, Text } from "@mantine/core";
import React from "react";
import { IconDots, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
interface Props {
userId: string;
}
export default function MemberActionMenu({ userId }: Props) {
const { t } = useTranslation();
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
const { isAdmin } = useUserRole();
const onRevoke = async () => {
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
};
const openRevokeModal = () =>
modals.openConfirmModal({
title: t("Delete member"),
children: (
<Text size="sm">
{t(
"Are you sure you want to delete this workspace member? This action is irreversible.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
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
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} />}
disabled={!isAdmin}
>
{t("Delete member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@ -17,7 +17,6 @@ import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import MemberActionMenu from "@/features/workspace/components/members/components/members-action-menu.tsx";
export default function WorkspaceMembersTable() {
const { t } = useTranslation();
@ -97,9 +96,6 @@ export default function WorkspaceMembersTable() {
disabled={!isAdmin}
/>
</Table.Td>
<Table.Td>
{isAdmin && <MemberActionMenu userId={user.id} />}
</Table.Td>
</Table.Tr>
))
) : (

View File

@ -16,7 +16,6 @@ import {
getWorkspace,
getWorkspacePublicData,
getAppVersion,
deleteWorkspaceMember,
} from "@/features/workspace/services/workspace-service";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
@ -57,30 +56,6 @@ export function useWorkspaceMembersQuery(
});
}
export function useDeleteWorkspaceMemberMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
userId: string;
}
>({
mutationFn: (data) => deleteWorkspaceMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Member deleted successfully" });
queryClient.invalidateQueries({
queryKey: ["workspaceMembers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useChangeMemberRoleMutation() {
const queryClient = useQueryClient();

View File

@ -36,12 +36,6 @@ export async function getWorkspaceMembers(
return req.data;
}
export async function deleteWorkspaceMember(data: {
userId: string;
}): Promise<void> {
await api.post("/workspace/members/delete", data);
}
export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data;

View File

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

View File

@ -1,16 +0,0 @@
import { timeAgo } from "@/lib/time.ts";
import { useEffect, useState } from "react";
export function useTimeAgo(date: Date | string) {
const [value, setValue] = useState(() => timeAgo(new Date(date)));
useEffect(() => {
const interval = setInterval(() => {
setValue(timeAgo(new Date(date)));
}, 5 * 1000);
return () => clearInterval(interval);
}, [date]);
return value;
}

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import {
afterUnloadDocumentPayload,
Extension,
onChangePayload,
onLoadDocumentPayload,
onStoreDocumentPayload,
} from '@hocuspocus/server';
@ -28,7 +26,6 @@ import { Page } from '@docmost/db/types/entity.types';
@Injectable()
export class PersistenceExtension implements Extension {
private readonly logger = new Logger(PersistenceExtension.name);
private contributors: Map<string, Set<string>> = new Map();
constructor(
private readonly pageRepo: PageRepo,
@ -119,27 +116,12 @@ export class PersistenceExtension implements Extension {
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from(
new Set([...existingContributors, ...newContributors]),
);
this.contributors.delete(documentName);
} catch (err) {
this.logger.debug('Contributors error:' + err?.['message']);
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
contributorIds: contributorIds,
},
pageId,
trx,
@ -170,21 +152,4 @@ export class PersistenceExtension implements Extension {
} as IPageBacklinkJob);
}
}
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user.id;
if (!userId) return;
if (!this.contributors.has(documentName)) {
this.contributors.set(documentName, new Set());
}
this.contributors.get(documentName).add(userId);
}
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
const documentName = data.documentName;
this.contributors.delete(documentName);
}
}

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