Compare commits

..

1 Commits

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

View File

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

View File

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

View File

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

View File

@ -351,37 +351,5 @@
"Created at: {{time}}": "Erstellt am: {{time}}", "Created at: {{time}}": "Erstellt am: {{time}}",
"Edited by {{name}} {{time}}": "Bearbeitet von {{name}} {{time}}", "Edited by {{name}} {{time}}": "Bearbeitet von {{name}} {{time}}",
"Word count: {{wordCount}}": "Wortanzahl: {{wordCount}}", "Word count: {{wordCount}}": "Wortanzahl: {{wordCount}}",
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}", "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"
} }

View File

@ -353,38 +353,5 @@
"Word count: {{wordCount}}": "Word count: {{wordCount}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}", "Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update", "New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available", "{{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"
} }

View File

@ -94,7 +94,7 @@
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder", "Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
"Join the workspace": "Unirse al espacio de trabajo", "Join the workspace": "Unirse al espacio de trabajo",
"Language": "Idioma", "Language": "Idioma",
"Light": "Claro", "Light": "Ligero",
"Link copied": "Enlace copiado", "Link copied": "Enlace copiado",
"Login": "Iniciar sesión", "Login": "Iniciar sesión",
"Logout": "Cerrar sesión", "Logout": "Cerrar sesión",
@ -351,37 +351,5 @@
"Created at: {{time}}": "Creado a: {{time}}", "Created at: {{time}}": "Creado a: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}", "Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
"Word count: {{wordCount}}": "Conteo de palabras: {{wordCount}}", "Word count: {{wordCount}}": "Conteo de palabras: {{wordCount}}",
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}", "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"
} }

View File

@ -21,7 +21,7 @@
"Can view": "Peut voir", "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.", "Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.",
"Cancel": "Annuler", "Cancel": "Annuler",
"Change email": "Changer le courriel", "Change email": "Changer l'email",
"Change password": "Changer le mot de passe", "Change password": "Changer le mot de passe",
"Change photo": "Changer la photo", "Change photo": "Changer la photo",
"Choose a role": "Choisir un rôle", "Choose a role": "Choisir un rôle",
@ -351,37 +351,5 @@
"Created at: {{time}}": "Créé à : {{time}}", "Created at: {{time}}": "Créé à : {{time}}",
"Edited by {{name}} {{time}}": "Modifié par {{name}} {{time}}", "Edited by {{name}} {{time}}": "Modifié par {{name}} {{time}}",
"Word count: {{wordCount}}": "Nombre de mots : {{wordCount}}", "Word count: {{wordCount}}": "Nombre de mots : {{wordCount}}",
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}", "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"
} }

View File

@ -347,41 +347,9 @@
"Members added successfully": "Membri aggiunti con successo", "Members added successfully": "Membri aggiunti con successo",
"Member removed successfully": "Membro rimosso con successo", "Member removed successfully": "Membro rimosso con successo",
"Member role updated successfully": "Ruolo del membro aggiornato con successo", "Member role updated successfully": "Ruolo del membro aggiornato con successo",
"Created by: <b>{{creatorName}}</b>": "Creato da: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Creato il: {{time}}", "Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Modificato da {{name}} il {{time}}", "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Conteggio parole: {{wordCount}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}", "Character count: {{characterCount}}": "Character count: {{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"
} }

View File

@ -347,41 +347,9 @@
"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>": "作成者: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "が作成しました:{{time}}", "Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}", "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}", "Character count: {{characterCount}}": "Character count: {{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": "ページの共有に失敗しました"
} }

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택", "Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",
"Select theme": "배경 선택", "Select theme": "배경 선택",
"Send invitation": "초대 보내기", "Send invitation": "초대 보내기",
"Invitation sent": "초대 발송 완료", "Invitation sent": "Invitation sent",
"Settings": "설정", "Settings": "설정",
"Setup workspace": "Workspace 설정", "Setup workspace": "Workspace 설정",
"Sign In": "로그인", "Sign In": "로그인",
@ -245,7 +245,7 @@
"Align left": "왼쪽 정렬", "Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬", "Align right": "오른쪽 정렬",
"Align center": "가운데 정렬", "Align center": "가운데 정렬",
"Justify": "정렬", "Justify": "Justify",
"Merge cells": "셀 병합", "Merge cells": "셀 병합",
"Split cell": "셀 분할", "Split cell": "셀 분할",
"Delete column": "열 삭제", "Delete column": "열 삭제",
@ -341,47 +341,15 @@
"Names do not match": "이름이 일치하지 않습니다", "Names do not match": "이름이 일치하지 않습니다",
"Today, {{time}}": "오늘, {{time}}", "Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}", "Yesterday, {{time}}": "어제, {{time}}",
"Space created successfully": "공간 생성 완료", "Space created successfully": "Space created successfully",
"Space updated successfully": "공간이 성공적으로 업데이트되었습니다", "Space updated successfully": "Space updated successfully",
"Space deleted successfully": "스페이스 삭제 완료", "Space deleted successfully": "Space deleted successfully",
"Members added successfully": "회원 추가 완료", "Members added successfully": "Members added successfully",
"Member removed successfully": "멤버가 성공적으로 제거되었습니다", "Member removed successfully": "Member removed successfully",
"Member role updated successfully": "회원 역할이 성공적으로 업데이트되었습니다", "Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "생성 날짜: {{time}}", "Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 편집함 {{time}}", "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "단어 수: {{wordCount}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "문자 수: {{characterCount}}", "Character count: {{characterCount}}": "Character count: {{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": "페이지 공유에 실패했습니다"
} }

View File

@ -351,37 +351,5 @@
"Created at: {{time}}": "Aangemaakt op: {{time}}", "Created at: {{time}}": "Aangemaakt op: {{time}}",
"Edited by {{name}} {{time}}": "Bewerkt door {{name}} {{time}}", "Edited by {{name}} {{time}}": "Bewerkt door {{name}} {{time}}",
"Word count: {{wordCount}}": "Aantal woorden: {{wordCount}}", "Word count: {{wordCount}}": "Aantal woorden: {{wordCount}}",
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}", "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"
} }

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 role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
"Select theme": "Selecionar tema", "Select theme": "Selecionar tema",
"Send invitation": "Enviar convite", "Send invitation": "Enviar convite",
"Invitation sent": "Convite enviado", "Invitation sent": "Invitation sent",
"Settings": "Configurações", "Settings": "Configurações",
"Setup workspace": "Configurar workspace", "Setup workspace": "Configurar workspace",
"Sign In": "Entrar", "Sign In": "Entrar",
@ -245,7 +245,7 @@
"Align left": "Alinhar à esquerda", "Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita", "Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro", "Align center": "Alinhar ao centro",
"Justify": "Justificar", "Justify": "Justify",
"Merge cells": "Mesclar células", "Merge cells": "Mesclar células",
"Split cell": "Dividir célula", "Split cell": "Dividir célula",
"Delete column": "Excluir coluna", "Delete column": "Excluir coluna",
@ -341,47 +341,15 @@
"Names do not match": "Os nomes não coincidem", "Names do not match": "Os nomes não coincidem",
"Today, {{time}}": "Hoje, {{time}}", "Today, {{time}}": "Hoje, {{time}}",
"Yesterday, {{time}}": "Ontem, {{time}}", "Yesterday, {{time}}": "Ontem, {{time}}",
"Space created successfully": "Espaço criado com sucesso", "Space created successfully": "Space created successfully",
"Space updated successfully": "Espaço atualizado com sucesso", "Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Espaço excluído com sucesso", "Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Membros adicionados com sucesso", "Members added successfully": "Members added successfully",
"Member removed successfully": "Membro removido com sucesso", "Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Função do membro atualizada com sucesso", "Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Criado por: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Criado em: {{time}}", "Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}", "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Contagem de palavras: {{wordCount}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}", "Character count: {{characterCount}}": "Character count: {{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"
} }

View File

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

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色", "Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",
"Select theme": "选择主题", "Select theme": "选择主题",
"Send invitation": "发送邀请", "Send invitation": "发送邀请",
"Invitation sent": "邀请邮件已发送", "Invitation sent": "Invitation sent",
"Settings": "设置", "Settings": "设置",
"Setup workspace": "设置工作空间", "Setup workspace": "设置工作空间",
"Sign In": "登录", "Sign In": "登录",
@ -245,7 +245,7 @@
"Align left": "靠左对齐", "Align left": "靠左对齐",
"Align right": "靠右对齐", "Align right": "靠右对齐",
"Align center": "居中对齐", "Align center": "居中对齐",
"Justify": "两端对齐", "Justify": "Justify",
"Merge cells": "合并单元格", "Merge cells": "合并单元格",
"Split cell": "分割单元格", "Split cell": "分割单元格",
"Delete column": "删除整列", "Delete column": "删除整列",
@ -298,7 +298,7 @@
"Heading 2": "2 级标题", "Heading 2": "2 级标题",
"Heading 3": "3 级标题", "Heading 3": "3 级标题",
"To-do List": "代办列表", "To-do List": "代办列表",
"Bullet List": "无列表", "Bullet List": "无列表",
"Numbered List": "有序列表", "Numbered List": "有序列表",
"Blockquote": "引用块", "Blockquote": "引用块",
"Just start typing with plain text.": "只需开始键入纯文本", "Just start typing with plain text.": "只需开始键入纯文本",
@ -341,47 +341,15 @@
"Names do not match": "名称不匹配", "Names do not match": "名称不匹配",
"Today, {{time}}": "今天,{{time}}", "Today, {{time}}": "今天,{{time}}",
"Yesterday, {{time}}": "昨天,{{time}}", "Yesterday, {{time}}": "昨天,{{time}}",
"Space created successfully": "空间创建成功", "Space created successfully": "Space created successfully",
"Space updated successfully": "空间更新成功", "Space updated successfully": "Space updated successfully",
"Space deleted successfully": "空间已成功删除", "Space deleted successfully": "Space deleted successfully",
"Members added successfully": "成员添加成功", "Members added successfully": "Members added successfully",
"Member removed successfully": "成员移除成功", "Member removed successfully": "Member removed successfully",
"Member role updated successfully": "成员角色更新成功", "Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "创建者:<b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "创建于:{{time}}", "Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "{{name}} 编辑于 {{time}}", "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "字数:{{wordCount}}", "Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "字符数:{{characterCount}}", "Character count: {{characterCount}}": "Character count: {{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": "页面分享失败"
} }

View File

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

View File

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

View File

@ -21,7 +21,7 @@ export default function Paginate({
} }
return ( return (
<Group mt="md" justify="flex-end"> <Group mt="md">
<Button <Button
variant="default" variant="default"
size="compact-sm" 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 { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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() { export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom); const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
let title: string; let title: string;
let component: ReactNode; let component: ReactNode;
@ -21,10 +17,6 @@ export default function Aside() {
component = <CommentList />; component = <CommentList />;
title = "Comments"; title = "Comments";
break; break;
case "toc":
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
default: default:
component = null; component = null;
title = 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 Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css"; import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx"; import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
export default function GlobalAppShell({ export default function GlobalAppShell({
children, children,
@ -23,7 +22,6 @@ export default function GlobalAppShell({
}) { }) {
useTrialEndAction(); useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom); const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
@ -113,7 +111,7 @@ export default function GlobalAppShell({
)} )}
<AppShell.Main> <AppShell.Main>
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={850}>{children}</Container> <Container size={800}>{children}</Container>
) : ( ) : (
children children
)} )}

View File

@ -35,12 +35,6 @@ export default function AppVersion() {
position="middle-end" position="middle-end"
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
disabled={!hasUpdate} disabled={!hasUpdate}
onClick={() => {
window.open(
"https://github.com/docmost/docmost/releases",
"_blank",
);
}}
> >
<Text <Text
size="sm" 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 { QueryParams } from "@/lib/types.ts";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts"; import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getSsoProviders } from '@/ee/security/services/security-service.ts';
import { getShares } from "@/features/share/services/share-service.ts";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -58,10 +57,3 @@ export const prefetchSsoProviders = () => {
queryFn: () => getSsoProviders(), queryFn: () => getSsoProviders(),
}); });
}; };
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
export enum BillingPlan { export enum BillingPlan {
STANDARD = "standard", STANDARD = "standard",
BUSINESS = "business",
} }
export interface IBilling { export interface IBilling {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { Button, Group, Tooltip } from "@mantine/core"; import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type CommentActionsProps = { type CommentActionsProps = {

View File

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

View File

@ -8,12 +8,10 @@ import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react"; import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
interface CommentEditorProps { interface CommentEditorProps {
defaultContent?: any; defaultContent?: any;
onUpdate?: any; onUpdate?: any;
onSave?: any;
editable: boolean; editable: boolean;
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
@ -24,7 +22,6 @@ const CommentEditor = forwardRef(
{ {
defaultContent, defaultContent,
onUpdate, onUpdate,
onSave,
editable, editable,
placeholder, placeholder,
autofocus, autofocus,
@ -45,35 +42,7 @@ const CommentEditor = forwardRef(
}), }),
Underline, Underline,
Link, Link,
EmojiCommand,
], ],
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (
[
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Enter",
].includes(event.key)
) {
const emojiCommand = document.querySelector("#emoji-command");
if (emojiCommand) {
return true;
}
}
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
if (onSave) onSave();
return true;
}
},
},
},
onUpdate({ editor }) { onUpdate({ editor }) {
if (onUpdate) onUpdate(editor.getJSON()); if (onUpdate) onUpdate(editor.getJSON());
}, },
@ -84,10 +53,6 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
useEffect(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
if (autofocus) { if (autofocus) {

View File

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

View File

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

View File

@ -11,26 +11,25 @@
border-left: 2px solid var(--mantine-color-gray-6); border-left: 2px solid var(--mantine-color-gray-6);
padding: 8px; padding: 8px;
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
cursor: pointer;
} }
.commentEditor { .commentEditor {
.focused { .focused {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--mantine-color-blue-3); box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
} }
.ProseMirror :global(.ProseMirror){ .ProseMirror {
border-radius: var(--mantine-radius-sm); width: 100%;
max-width: 100%; max-width: 100%;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
max-height: 20vh; max-height: 20vh;
padding-left: 6px; padding-left: 6px;
padding-right: 6px; padding-right: 6px;
margin-top: 10px; margin-top: 2px;
margin-bottom: 2px; margin-bottom: 2px;
font-size: 14px;
overflow: hidden auto; 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 titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import React, {
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@ -19,7 +18,7 @@ import {
import clsx from "clsx"; import clsx from "clsx";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IconFileDescription, IconPlus } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
@ -29,28 +28,14 @@ import {
MentionListProps, MentionListProps,
MentionSuggestionItem, MentionSuggestionItem,
} from "@/features/editor/components/mention/mention.type.ts"; } from "@/features/editor/components/mention/mention.type.ts";
import { IPage } from "@/features/page/types/page.types";
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { extractPageSlugId } from "@/lib";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => { const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1); const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const { pageSlug, spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug); const { data: space } = useSpaceQuery(spaceSlug);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]); const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query, query: props.query,
@ -60,23 +45,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
limit: 10, limit: 10,
}); });
const createPageItem = (label: string) : MentionSuggestionItem => {
return {
id: null,
label: label,
entityType: "page",
entityId: null,
slugId: null,
icon: null,
}
}
useEffect(() => { useEffect(() => {
if (suggestion && !isLoading) { if (suggestion && !isLoading) {
let items: MentionSuggestionItem[] = []; let items: MentionSuggestionItem[] = [];
if (suggestion?.users?.length > 0) { if (suggestion?.users?.length > 0) {
items.push({ entityType: "header", label: t("Users") }); items.push({ entityType: "header", label: "Users" });
items = items.concat( items = items.concat(
suggestion.users.map((user) => ({ suggestion.users.map((user) => ({
@ -90,7 +64,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
} }
if (suggestion?.pages?.length > 0) { if (suggestion?.pages?.length > 0) {
items.push({ entityType: "header", label: t("Pages") }); items.push({ entityType: "header", label: "Pages" });
items = items.concat( items = items.concat(
suggestion.pages.map((page) => ({ suggestion.pages.map((page) => ({
id: uuid7(), id: uuid7(),
@ -102,7 +76,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})), })),
); );
} }
items.push(createPageItem(props.query));
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
@ -123,7 +96,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id, creatorId: currentUser?.user.id,
}); });
} }
if (item.entityType === "page" && item.id!==null) { if (item.entityType === "page") {
props.command({ props.command({
id: item.id, id: item.id,
label: item.label || "Untitled", label: item.label || "Untitled",
@ -133,9 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id, creatorId: currentUser?.user.id,
}); });
} }
if (item.entityType === "page" && item.id===null) {
createPage(item.label);
}
} }
}, },
[renderItems], [renderItems],
@ -197,58 +167,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}, },
})); }));
const createPage = async (title: string) => {
const payload: { spaceId: string; parentPageId?: string; title: string } = {
spaceId: space.id,
parentPageId: page.id || null,
title: title
};
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null;
const data = {
id: createdPage.id,
slugId: createdPage.slugId,
name: createdPage.title,
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
children: [],
} as any;
const lastIndex = tree.data.length;
tree.create({ parentId, index: lastIndex, data });
setData(tree.data);
props.command({
id: uuid7(),
label: createdPage.title || "Untitled",
entityType: "page",
entityId: createdPage.id,
slugId: createdPage.slugId,
creatorId: currentUser?.user.id,
});
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: space.id,
payload: {
parentId,
index: lastIndex,
data,
},
});
}, 50);
} catch (err) {
throw new Error("Failed to create page");
}
}
// if no results and enter what to do? // if no results and enter what to do?
useEffect(() => { useEffect(() => {
@ -260,7 +178,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (renderItems.length === 0) { if (renderItems.length === 0) {
return ( return (
<Paper shadow="md" p="xs" withBorder> <Paper shadow="md" p="xs" withBorder>
{ t("No results") } No results
</Paper> </Paper>
); );
} }
@ -330,14 +248,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
color="gray" color="gray"
size={18} size={18}
> >
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> } <IconFileDescription size={18} />
</ActionIcon> </ActionIcon>
)} )}
</ActionIcon> </ActionIcon>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{ (item.id) ? item.label : t("Create page") + ': ' + item.label } {item.label}
</Text> </Text>
</div> </div>
</Group> </Group>

View File

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

View File

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

@ -58,7 +58,6 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap";
import elixir from "highlight.js/lib/languages/elixir"; import elixir from "highlight.js/lib/languages/elixir";
import erlang from "highlight.js/lib/languages/erlang"; import erlang from "highlight.js/lib/languages/erlang";
import dockerfile from "highlight.js/lib/languages/dockerfile"; import dockerfile from "highlight.js/lib/languages/dockerfile";
@ -77,7 +76,7 @@ import { CharacterCount } from "@tiptap/extension-character-count";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
lowlight.register("powershell", powershell); lowlight.register("powershell", powershell);
lowlight.register("abap", abap); lowlight.register("powershell", powershell);
lowlight.register("erlang", erlang); lowlight.register("erlang", erlang);
lowlight.register("elixir", elixir); lowlight.register("elixir", elixir);
lowlight.register("dockerfile", dockerfile); lowlight.register("dockerfile", dockerfile);

View File

@ -52,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@ -84,6 +83,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false); const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const collabRetryCount = useRef(0);
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const localProvider = useMemo(() => { const localProvider = useMemo(() => {
@ -105,11 +105,13 @@ export default function PageEditor({
connect: false, connect: false,
preserveConnection: false, preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token); collabRetryCount.current = collabRetryCount.current + 1;
const now = Date.now().valueOf() / 1000; refetchCollabToken().then(() => {
const isTokenExpired = now >= payload.exp; collabRetryCount.current = 0;
if (isTokenExpired) { });
refetchCollabToken();
if (collabRetryCount.current > 20) {
window.location.reload();
} }
}, },
onStatus: (status) => { onStatus: (status) => {
@ -209,7 +211,6 @@ export default function PageEditor({
queryClient.setQueryData(["pages", slugId], { queryClient.setQueryData(["pages", slugId], {
...pageData, ...pageData,
content: newContent, content: newContent,
updatedAt: new Date(),
}); });
} }
}, 3000); }, 3000);
@ -219,12 +220,9 @@ export default function PageEditor({
setActiveCommentId(commentId); setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
//wait if aside is closed
setTimeout(() => {
const selector = `div[data-comment-id="${commentId}"]`; const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector); const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); commentElement?.scrollIntoView();
}, 400);
}; };
useEffect(() => { useEffect(() => {
@ -267,13 +265,19 @@ export default function PageEditor({
documentState === "visible" && documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected remoteProvider?.status === WebSocketStatus.Disconnected
) { ) {
resetIdle(); const reconnectTimeout = setTimeout(
() => {
remoteProvider.connect(); remoteProvider.connect();
setTimeout(() => { resetIdle();
},
collabRetryCount.current > 2 ? 3000 : 0,
);
setIsCollabReady(true); setIsCollabReady(true);
}, 600);
return () => clearTimeout(reconnectTimeout);
} }
}, [isIdle, documentState, remoteProvider]); }, [isIdle, documentState, remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
@ -282,7 +286,7 @@ export default function PageEditor({
if ( if (
!isCollabReady && !isCollabReady &&
isSynced && isSynced &&
remoteProvider?.status === WebSocketStatus.Connected remoteProvider.status === WebSocketStatus.Connected
) { ) {
setIsCollabReady(true); 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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,13 +17,6 @@ import {
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; 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 { interface Props {
pageId: string; pageId: string;
@ -43,11 +36,6 @@ function HistoryList({ pageId }: Props) {
const [mainEditorTitle] = useAtom(titleEditorAtom); const [mainEditorTitle] = useAtom(titleEditorAtom);
const [, setHistoryModalOpen] = useAtom(historyAtoms); const [, setHistoryModalOpen] = useAtom(historyAtoms);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const confirmModal = () => const confirmModal = () =>
modals.openConfirmModal({ modals.openConfirmModal({
title: t("Please confirm your action"), title: t("Please confirm your action"),
@ -115,12 +103,8 @@ function HistoryList({ pageId }: Props) {
))} ))}
</ScrollArea> </ScrollArea>
{spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) ? null : (
<>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}> <Button size="compact-md" onClick={confirmModal}>
{t("Restore")} {t("Restore")}
@ -133,8 +117,6 @@ function HistoryList({ pageId }: Props) {
{t("Cancel")} {t("Cancel")}
</Button> </Button>
</Group> </Group>
</>
)}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
ICopyPageToSpace,
IExportPageParams, IExportPageParams,
IMovePage, IMovePage,
IMovePageToSpace,
IPage, IPage,
IPageInput, IPageInput,
SidebarPagesParams, SidebarPagesParams,
@ -36,15 +34,6 @@ export async function movePage(data: IMovePage): Promise<void> {
await api.post<void>("/pages/move", data); 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( export async function getSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<IPagination<IPage>> { ): Promise<IPagination<IPage>> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +0,0 @@
import { Affix, Button } from "@mantine/core";
export default function ShareBranding() {
return (
<Affix position={{ bottom: 20, right: 20 }}>
<Button
variant="default"
component="a"
target="_blank"
href="https://docmost.com?ref=public-share"
>
Powered by Docmost
</Button>
</Affix>
);
}

View File

@ -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,187 +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";
import ShareBranding from '@/features/share/components/share-branding.tsx';
const MemoizedSharedTree = React.memo(SharedTree);
export default function ShareShell({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [tocOpened] = useAtom(tableOfContentAsideAtom);
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
const toggleToc = useToggleToc(tableOfContentAsideAtom);
const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
return (
<AppShell
header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && {
navbar: {
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
},
})}
aside={{
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileTocOpened,
desktop: !tocOpened,
},
}}
padding="md"
>
<AppShell.Header>
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
<Group wrap="nowrap">
{data?.pageTree?.length > 1 && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
</Group>
{shareId && (
<Group visibleFrom="sm">
<SearchControl onClick={shareSearchSpotlight.open} />
</Group>
)}
<Group>
<>
{shareId && (
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
</Group>
)}
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={toggleTocMobile}
hiddenFrom="sm"
size="sm"
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={toggleToc}
visibleFrom="sm"
size="sm"
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
<ThemeToggle />
</Group>
</Group>
</AppShell.Header>
{data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md" className={classes.navbar}>
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
<AppShell.Main>
{children}
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
</AppShell.Main>
<AppShell.Aside
p="md"
withBorder={mobileTocOpened}
className={classes.aside}
>
<ScrollArea style={{ height: "80vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "50px" }}>
{readOnlyEditor && (
<TableOfContents isShare={true} editor={readOnlyEditor} />
)}
</div>
</ScrollArea>
</AppShell.Aside>
<ShareSearchSpotlight shareId={shareId} />
</AppShell>
);
}

View File

@ -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,75 +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 };
};
hasLicenseKey: boolean;
}
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[]>;
hasLicenseKey: boolean;
}

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

View File

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

View File

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

View File

@ -1,11 +1,4 @@
import { import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
Group,
Table,
Text,
Menu,
ActionIcon,
ScrollArea,
} from "@mantine/core";
import React from "react"; import React from "react";
import { IconDots } from "@tabler/icons-react"; import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
@ -113,7 +106,6 @@ export default function SpaceMembersList({
return ( return (
<> <>
<SearchInput onSearch={handleSearch} /> <SearchInput onSearch={handleSearch} />
<ScrollArea h={400}>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}> <Table highlightOnHover verticalSpacing={8}>
<Table.Thead> <Table.Thead>
@ -201,7 +193,6 @@ export default function SpaceMembersList({
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
</ScrollArea>
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate

View File

@ -70,6 +70,7 @@ function ChangeEmailForm() {
function handleSubmit(data: FormValues) { function handleSubmit(data: FormValues) {
setIsLoading(true); setIsLoading(true);
console.log(data);
} }
return ( 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 { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.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 React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -26,7 +26,6 @@ interface PageWidthToggleProps {
size?: MantineSize; size?: MantineSize;
label?: string; label?: string;
} }
export function PageWidthToggle({ size, label }: PageWidthToggleProps) { export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);

View File

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

View File

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

View File

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

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