Compare commits

...

33 Commits

Author SHA1 Message Date
b52dd5245b switch from ref to useState 2025-04-20 20:24:43 +01:00
862f6d4820 use non-esm nanoid version (#1040) 2025-04-19 19:45:09 +01:00
de57d05199 0.10.2 2025-04-15 12:48:40 +01:00
89ec990232 sync ee 2025-04-15 12:46:28 +01:00
49d0f1cc9a Add click handler 2025-04-11 13:41:43 +01:00
268001ae26 v0.10.1 2025-04-11 13:23:42 +01:00
27fa45a769 fix local attachment paths in exports (#1013) 2025-04-11 13:18:44 +01:00
f9711918a3 fix comment editor padding 2025-04-11 12:32:54 +01:00
29bb52db0c v0.10.0 2025-04-09 19:14:51 +01:00
f2241db5ee remove beta message 2025-04-09 19:14:33 +01:00
58d1855a36 fix hash check 2025-04-09 19:03:27 +01:00
7fe3c5f177 * time ago hook 2025-04-09 18:47:39 +01:00
5fd477d074 collapse by default in node-edit mode 2025-04-09 15:46:29 +01:00
4aa5d7e326 hide history action menu for can-view role (#1001) 2025-04-09 15:42:29 +01:00
7f7f2bccd0 fix toggle node in non-edit mode 2025-04-09 15:37:18 +01:00
a9f370660b New Crowdin updates (#1005)
* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-04-08 17:28:33 +01:00
117c7049ff fix 2025-04-08 17:15:09 +01:00
cd10365f71 new translations 2025-04-08 17:10:48 +01:00
ee30d9d0f2 New Crowdin updates (#1003)
* New translations translation.json (French)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2025-04-08 17:10:08 +01:00
276ececbf2 cleanup 2025-04-08 17:06:32 +01:00
fa194a497c cleanup 2025-04-08 17:04:43 +01:00
1eaba6e77f fix: bug fixes (#1000)
* sort by groups first

* add scroll area

* fix group members pagination

* move pagination to the right
2025-04-08 13:34:00 +01:00
651e5f6153 null check 2025-04-08 11:59:47 +01:00
7431804a46 feat: delete workspace member (#987)
* add delete user endpoint (server)

* delete user (UI)

* prevent token generation

* more checks
2025-04-07 19:26:03 +01:00
3559358d14 fix pagination issue where user is not part of any space 2025-04-07 19:09:02 +01:00
06270ff747 - fixes
- allow mail from address override
- queue cloud emails
2025-04-07 19:07:10 +01:00
233536314f feat: add Table of contents (#981)
* chore: add table of contents module

* refactor

* lint

* null check

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-04-05 19:03:42 +01:00
17ce3bab8a feat: move page between spaces (#988)
* feat: Move the page to another space

- The ability to move a page to another space has been added

* feat: Move the page to another space
* feat: Move the page to another space

- Correction of the visibility attribute of elements that extend beyond the boundaries of the space selection modal window

* feat: Move the page to another space

- Added removal of query keys when moving pages

* feat: Move the page to another space

- Fix locales

* feat: Move the page to another space
* feat: Move the page to another space

- Fix docker compose

* feat: Move the page to another space

* feat: Move the page to another space

- Some refactor

* feat: Move the page to another space

- Attachments update

* feat: Move the page to another space

- The function of searching for attachments by page ID and updating attachments has been combined

* feat: Move the page to another space

- Fix variable name

* feat: Move the page to another space

- Move current space to parameter of component SpaceSelectionModal

* refactor ui

---------

Co-authored-by: plekhanov <astecom@mail.ru>
2025-04-04 23:44:18 +01:00
b27d1708b0 queue trial ended job (#992) 2025-04-04 23:35:08 +01:00
64f0531093 feat: keep track of page contributors (#959)
* WIP

* feat: store and retrieve page contributors
2025-04-04 13:03:57 +01:00
8aa604637e feat: nested toggle block (#671)
* feat: nested toggle block

* fix: md export

* fix detailsButton icon alignment

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-04-04 13:01:39 +01:00
7ca2b437d4 sync 2025-04-03 14:08:06 +01:00
595bd1dc81 Fix editor connection loop (#986)
* fix editor connection loop

* remove query refresh
2025-04-03 14:05:34 +01:00
79 changed files with 1462 additions and 641 deletions

View File

@ -9,9 +9,6 @@
</div>
<br />
> [!NOTE]
> Docmost is currently in **beta**. We value your feedback as we progress towards a stable release.
## Getting started
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.9.0",
"version": "0.10.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -34,6 +34,7 @@
"jotai": "^2.12.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.21",
"lowlight": "^3.2.0",
"mermaid": "^11.4.1",

View File

@ -351,5 +351,16 @@
"Created at: {{time}}": "Erstellt am: {{time}}",
"Edited by {{name}} {{time}}": "Bearbeitet von {{name}} {{time}}",
"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."
}

View File

@ -353,5 +353,14 @@
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"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."
}

View File

@ -351,5 +351,16 @@
"Created at: {{time}}": "Creado a: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
"Word count: {{wordCount}}": "Conteo de palabras: {{wordCount}}",
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}"
"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."
}

View File

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

View File

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

View File

@ -347,9 +347,20 @@
"Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバーが削除されました",
"Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "が作成しました:{{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバーが削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
"Move": "移動",
"Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します。",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出しH1、H2、H3を追加して目次を生成します。"
}

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",
"Select theme": "배경 선택",
"Send invitation": "초대 보내기",
"Invitation sent": "Invitation sent",
"Invitation sent": "초대 발송 완료",
"Settings": "설정",
"Setup workspace": "Workspace 설정",
"Sign In": "로그인",
@ -245,7 +245,7 @@
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
"Align center": "가운데 정렬",
"Justify": "Justify",
"Justify": "정렬",
"Merge cells": "셀 병합",
"Split cell": "셀 분할",
"Delete column": "열 삭제",
@ -341,15 +341,26 @@
"Names do not match": "이름이 일치하지 않습니다",
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
"Space created successfully": "공간 생성 완료",
"Space updated successfully": "공간이 성공적으로 업데이트되었습니다",
"Space deleted successfully": "스페이스 삭제 완료",
"Members added successfully": "회원 추가 완료",
"Member removed successfully": "멤버가 성공적으로 제거되었습니다",
"Member role updated successfully": "회원 역할이 성공적으로 업데이트되었습니다",
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
"Created at: {{time}}": "생성 날짜: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 편집함 {{time}}",
"Word count: {{wordCount}}": "단어 수: {{wordCount}}",
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
"New update": "새로운 업데이트",
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
"Delete member": "회원 삭제",
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Move": "이동",
"Move page": "페이지 이동",
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
"Table of contents": "목차",
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요."
}

View File

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

View File

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

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "Выберите роль для всех приглашённых участников",
"Select theme": "Выберите тему",
"Send invitation": "Отправить приглашение",
"Invitation sent": "Invitation sent",
"Invitation sent": "Приглашение отправлено",
"Settings": "Настройки",
"Setup workspace": "Настроить рабочее пространство",
"Sign In": "Вход",
@ -245,7 +245,7 @@
"Align left": "По левому краю",
"Align right": "По правому краю",
"Align center": "По центру",
"Justify": "Justify",
"Justify": "По ширине",
"Merge cells": "Объединить ячейки",
"Split cell": "Разделить ячейку",
"Delete column": "Удалить столбец",
@ -331,25 +331,36 @@
"Insert math equation": "Вставить математическое выражение",
"Mermaid diagram": "Диаграмма Mermaid",
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
"Insert and design Drawio diagrams": "Вставьте и редактируйте диаграммы Draw.io",
"Insert and design Drawio diagrams": "Вставить и рисовать диаграммы Draw.io",
"Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Создайте и рисуйте диаграммы Excalidraw",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"Multiple": "Несколько",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для выбора команд",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
"Names do not match": "Названия не совпадают",
"Today, {{time}}": "Сегодня, {{time}}",
"Yesterday, {{time}}": "Вчера, {{time}}",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
"Space created successfully": "Пространство успешно создано",
"Space updated successfully": "Пространство успешно обновлено",
"Space deleted successfully": "Пространство успешно удалено",
"Members added successfully": "Участники успешно добавлены",
"Member removed successfully": "Участник успешно удален",
"Member role updated successfully": "Роль участника успешно обновлена",
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Дата создания: {{time}}",
"Edited by {{name}} {{time}}": "Изменено {{name}} {{time}}",
"Word count: {{wordCount}}": "Количество слов: {{wordCount}}",
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
"New update": "Новое обновление",
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
"Delete member": "Удалить участника",
"Member deleted successfully": "Участник успешно удален",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
"Move": "Переместить",
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Table of contents": "Содержание",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление."
}

View File

@ -148,7 +148,7 @@
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",
"Select theme": "选择主题",
"Send invitation": "发送邀请",
"Invitation sent": "Invitation sent",
"Invitation sent": "邀请邮件已发送",
"Settings": "设置",
"Setup workspace": "设置工作空间",
"Sign In": "登录",
@ -245,7 +245,7 @@
"Align left": "靠左对齐",
"Align right": "靠右对齐",
"Align center": "居中对齐",
"Justify": "Justify",
"Justify": "两端对齐",
"Merge cells": "合并单元格",
"Split cell": "分割单元格",
"Delete column": "删除整列",
@ -341,15 +341,26 @@
"Names do not match": "名称不匹配",
"Today, {{time}}": "今天,{{time}}",
"Yesterday, {{time}}": "昨天,{{time}}",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}"
"Space created successfully": "空间创建成功",
"Space updated successfully": "空间更新成功",
"Space deleted successfully": "空间已成功删除",
"Members added successfully": "成员添加成功",
"Member removed successfully": "成员移除成功",
"Member role updated successfully": "成员角色更新成功",
"Created by: <b>{{creatorName}}</b>": "创建者:<b>{{creatorName}}</b>",
"Created at: {{time}}": "创建于:{{time}}",
"Edited by {{name}} {{time}}": "{{name}} 编辑于 {{time}}",
"Word count: {{wordCount}}": "字数:{{wordCount}}",
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
"New update": "新更新",
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
"Delete member": "删除成员",
"Member deleted successfully": "成员删除成功",
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
"Move": "移动",
"Move page": "移动页面",
"Move page to a different space.": "将页面移动到不同的空间。",
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
"Table of contents": "目录",
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题H1H2H3以生成目录。"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
.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;
}
}

View File

@ -0,0 +1,165 @@
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>;
};
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();
}, []);
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 (
<>
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
</>
);
}
return (
<>
<div>
{links.map((item, idx) => (
<Box<"button">
component="button"
onClick={() => handleScrollToHeading(item.position)}
key={idx}
className={clsx(classes.link, {
[classes.linkActive]: item.element === activeElement,
})}
style={{
paddingLeft: `calc(${item.level} * var(--mantine-spacing-md))`,
}}
>
{item.label}
</Box>
))}
</div>
<div ref={headerPaddingRef} className={classes.headerPadding} />
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,98 @@
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();
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
color: "red",
});
console.log(err);
}
setTargetSpace(null);
};
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

@ -2,6 +2,7 @@ import api from "@/lib/api-client";
import {
IExportPageParams,
IMovePage,
IMovePageToSpace,
IPage,
IPageInput,
SidebarPagesParams,
@ -34,6 +35,10 @@ export async function movePage(data: IMovePage): Promise<void> {
await api.post<void>("/pages/move", data);
}
export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
await api.post<void>("/pages/move-to-space", data);
}
export async function getSidebarPages(
params: SidebarPagesParams,
): Promise<IPagination<IPage>> {

View File

@ -7,11 +7,12 @@ import {
usePageQuery,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
import React, { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Menu, rem } from "@mantine/core";
import {
IconArrowRight,
IconChevronDown,
IconChevronRight,
IconDotsVertical,
@ -56,6 +57,7 @@ import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import MovePageModal from "../../components/move-page-modal.tsx";
interface SpaceTreeProps {
spaceId: string;
@ -82,7 +84,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const isDataLoaded = useRef(false);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
@ -106,7 +108,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
// and append root pages instead of resetting the entire tree
// which looses async loaded children too
setData(treeData);
isDataLoaded.current = true;
setIsDataLoaded(true);
setOpenTreeNodes({});
}
}
@ -114,7 +116,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
useEffect(() => {
const fetchData = async () => {
if (isDataLoaded.current && currentPage) {
if (isDataLoaded && currentPage) {
// check if pageId node is present in the tree
const node = dfs(treeApiRef.current?.root, currentPage.id);
if (node) {
@ -176,7 +178,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
};
fetchData();
}, [isDataLoaded.current, currentPage?.id]);
}, [isDataLoaded, currentPage?.id]);
useEffect(() => {
if (currentPage?.id) {
@ -234,6 +236,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const emit = useQueryEmit();
const { spaceSlug } = useParams();
const timerRef = useRef(null);
const { t } = useTranslation();
const prefetchPage = () => {
timerRef.current = setTimeout(() => {
@ -369,7 +372,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
/>
</div>
<span className={classes.text}>{node.data.name || "untitled"}</span>
<span className={classes.text}>{node.data.name || t("untitled")}</span>
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} />
@ -434,6 +437,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const { openDeleteModal } = useDeletePageModal();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@ -486,8 +493,18 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
@ -504,6 +521,14 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
</Menu.Dropdown>
</Menu>
<MovePageModal
pageId={node.id}
slugId={node.data.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<ExportModal
type="page"
id={node.id}

View File

@ -42,6 +42,11 @@ export interface IMovePage {
parentPageId?: string;
}
export interface IMovePageToSpace {
pageId: string;
spaceId: string;
}
export interface SidebarPagesParams {
spaceId: string;
pageId?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.9.0",
"version": "0.10.2",
"description": "",
"author": "",
"private": true,
@ -59,14 +59,13 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"fix-esm": "^1.0.1",
"fs-extra": "^11.3.0",
"happy-dom": "^15.11.6",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.27.5",
"kysely-migration-cli": "^0.4.2",
"mime-types": "^2.1.35",
"nanoid": "^5.1.0",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.1.0",
"nodemailer": "^6.10.0",
"openid-client": "^5.7.1",

View File

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

View File

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

View File

@ -17,6 +17,9 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
}
if (job.name === QueueJob.DELETE_USER_AVATARS) {
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
}
} catch (err) {
throw err;
}

View File

@ -281,10 +281,42 @@ export class AttachmentService {
}),
);
if(failedDeletions.length === attachments.length){
throw new Error(`Failed to delete any attachments for spaceId: ${spaceId}`);
if (failedDeletions.length === attachments.length) {
throw new Error(
`Failed to delete any attachments for spaceId: ${spaceId}`,
);
}
} catch (err) {
throw err;
}
}
async handleDeleteUserAvatars(userId: string) {
try {
const userAvatars = await this.db
.selectFrom('attachments')
.select(['id', 'filePath'])
.where('creatorId', '=', userId)
.where('type', '=', AttachmentType.Avatar)
.execute();
if (!userAvatars || userAvatars.length === 0) {
return;
}
await Promise.all(
userAvatars.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
this.logger.log(
`DeleteUserAvatar: failed to delete user avatar ${attachment.id}:`,
err,
);
}
}),
);
} catch (err) {
throw err;
}

View File

@ -43,19 +43,22 @@ export class AuthService {
) {}
async login(loginDto: LoginDto, workspaceId: string) {
const user = await this.userRepo.findByEmail(
loginDto.email,
workspaceId,
{
includePassword: true
}
const user = await this.userRepo.findByEmail(loginDto.email, workspaceId, {
includePassword: true,
});
const errorMessage = 'email or password does not match';
if (!user || user?.deletedAt) {
throw new UnauthorizedException(errorMessage);
}
const isPasswordMatch = await comparePasswordHash(
loginDto.password,
user.password,
);
if (
!user ||
!(await comparePasswordHash(loginDto.password, user.password))
) {
throw new UnauthorizedException('email or password does not match');
if (!isPasswordMatch) {
throw new UnauthorizedException(errorMessage);
}
user.lastLoginAt = new Date();
@ -86,7 +89,7 @@ export class AuthService {
includePassword: true,
});
if (!user) {
if (!user || user.deletedAt) {
throw new NotFoundException('User not found');
}
@ -125,7 +128,7 @@ export class AuthService {
workspace.id,
);
if (!user) {
if (!user || user.deletedAt) {
return;
}
@ -168,7 +171,7 @@ export class AuthService {
}
const user = await this.userRepo.findById(userToken.userId, workspaceId);
if (!user) {
if (!user || user.deletedAt) {
throw new NotFoundException('User not found');
}

View File

@ -1,4 +1,8 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
@ -17,6 +21,10 @@ export class TokenService {
) {}
async generateAccessToken(user: User): Promise<string> {
if (user.deletedAt) {
throw new ForbiddenException();
}
const payload: JwtPayload = {
sub: user.id,
email: user.email,

View File

@ -1,9 +1,4 @@
import {
BadRequestException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@ -47,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
if (!user) {
if (!user || user.deletedAt) {
throw new UnauthorizedException();
}

View File

@ -13,3 +13,11 @@ export class MovePageDto {
@IsString()
parentPageId?: string | null;
}
export class MovePageToSpaceDto {
@IsString()
pageId: string;
@IsString()
spaceId: string;
}

View File

@ -7,11 +7,12 @@ import {
UseGuards,
ForbiddenException,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto } from './dto/move-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
@ -46,6 +47,7 @@ export class PageController {
includeContent: true,
includeCreator: true,
includeLastUpdatedBy: true,
includeContributors: true,
});
if (!page) {
@ -92,11 +94,7 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.update(
updatePageDto.pageId,
updatePageDto,
user.id,
);
return this.pageService.update(page, updatePageDto, user.id);
}
@HttpCode(HttpStatus.OK)
@ -209,6 +207,36 @@ export class PageController {
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
}
@HttpCode(HttpStatus.OK)
@Post('move-to-space')
async movePageToSpace(
@Body() dto: MovePageToSpaceDto,
@AuthUser() user: User,
) {
const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) {
throw new NotFoundException('Page to move not found');
}
if (movedPage.spaceId === dto.spaceId) {
throw new BadRequestException('Page is already in this space');
}
const abilities = await Promise.all([
this.spaceAbility.createForUser(user, movedPage.spaceId),
this.spaceAbility.createForUser(user, dto.spaceId),
]);
if (
abilities.some((ability) =>
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
)
) {
throw new ForbiddenException();
}
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
}
@HttpCode(HttpStatus.OK)
@Post('move')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {

View File

@ -19,11 +19,14 @@ import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
@Injectable()
export class PageService {
constructor(
private pageRepo: PageRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@ -60,12 +63,31 @@ export class PageService {
parentPageId = parentPage.id;
}
const createdPage = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
createPageDto.spaceId,
parentPageId,
),
icon: createPageDto.icon,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
});
return createdPage;
}
async nextPagePosition(spaceId: string, parentPageId?: string) {
let pagePosition: string;
const lastPageQuery = this.db
.selectFrom('pages')
.select(['id', 'position'])
.where('spaceId', '=', createPageDto.spaceId)
.select(['position'])
.where('spaceId', '=', spaceId)
.orderBy('position', 'desc')
.limit(1);
@ -96,37 +118,36 @@ export class PageService {
}
}
const createdPage = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
position: pagePosition,
icon: createPageDto.icon,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
});
return createdPage;
return pagePosition;
}
async update(
pageId: string,
page: Page,
updatePageDto: UpdatePageDto,
userId: string,
): Promise<Page> {
const contributors = new Set<string>(page.contributorIds);
contributors.add(userId);
const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage(
{
title: updatePageDto.title,
icon: updatePageDto.icon,
lastUpdatedById: userId,
updatedAt: new Date(),
contributorIds: contributorIds,
},
pageId,
page.id,
);
return await this.pageRepo.findById(pageId);
return await this.pageRepo.findById(page.id, {
includeSpace: true,
includeContent: true,
includeCreator: true,
includeLastUpdatedBy: true,
includeContributors: true,
});
}
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
@ -181,6 +202,36 @@ export class PageService {
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string) {
await executeTx(this.db, async (trx) => {
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
{ spaceId, parentPageId: null, position: nextPosition },
rootPage.id,
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id)
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
// Update sub pages
await this.pageRepo.updatePages(
{ spaceId },
pageIds.filter((id) => id !== rootPage.id),
trx,
);
}
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
trx,
);
});
}
async movePage(dto: MovePageDto, movedPage: Page) {
// validate position value by attempting to generate a key
try {

View File

@ -84,6 +84,7 @@ export class SearchService {
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.limit(limit)
.execute();
}

View File

@ -1,6 +1,6 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const),

View File

@ -1,10 +1,10 @@
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable()
export class UserService {
@ -27,8 +27,9 @@ export class UserService {
// preference update
if (typeof updateUserDto.fullPageWidth !== 'undefined') {
return this.updateUserPageWidthPreference(
return this.userRepo.updatePreference(
userId,
'fullPageWidth',
updateUserDto.fullPageWidth,
);
}
@ -55,12 +56,4 @@ export class UserService {
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
return user;
}
async updateUserPageWidthPreference(userId: string, fullPageWidth: boolean) {
return this.userRepo.updatePreference(
userId,
'fullPageWidth',
fullPageWidth,
);
}
}

View File

@ -34,6 +34,7 @@ import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
@UseGuards(JwtAuthGuard)
@Controller('workspace')
@ -120,6 +121,22 @@ export class WorkspaceController {
}
}
@HttpCode(HttpStatus.OK)
@Post('members/delete')
async deleteWorkspaceMember(
@Body() dto: RemoveWorkspaceUserDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
) {
throw new ForbiddenException();
}
await this.workspaceService.deleteUser(user, dto.userId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('members/change-role')
async updateWorkspaceMemberRole(

View File

@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
@ -26,9 +27,16 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import { v4 } from 'uuid';
import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
@Injectable()
export class WorkspaceService {
private readonly logger = new Logger(WorkspaceService.name);
constructor(
private workspaceRepo: WorkspaceRepo,
private spaceService: SpaceService,
@ -39,6 +47,8 @@ export class WorkspaceService {
private environmentService: EnvironmentService,
private domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
) {}
async findById(workspaceId: string) {
@ -91,13 +101,15 @@ export class WorkspaceService {
createWorkspaceDto: CreateWorkspaceDto,
trx?: KyselyTransaction,
) {
return await executeTx(
let trialEndAt = undefined;
const createdWorkspace = await executeTx(
this.db,
async (trx) => {
let hostname = undefined;
let trialEndAt = undefined;
let status = undefined;
let plan = undefined;
let billingEmail = undefined;
if (this.environmentService.isCloud()) {
// generate unique hostname
@ -110,6 +122,7 @@ export class WorkspaceService {
);
status = WorkspaceStatus.Active;
plan = 'standard';
billingEmail = user.email;
}
// create workspace
@ -121,6 +134,7 @@ export class WorkspaceService {
status,
trialEndAt,
plan,
billingEmail,
},
trx,
);
@ -195,6 +209,28 @@ export class WorkspaceService {
},
trx,
);
if (this.environmentService.isCloud() && trialEndAt) {
try {
const delay = trialEndAt.getTime() - Date.now();
await this.billingQueue.add(
QueueJob.TRIAL_ENDED,
{ workspaceId: createdWorkspace.id },
{ delay },
);
await this.billingQueue.add(
QueueJob.WELCOME_EMAIL,
{ userId: user.id },
{ delay: 60 * 1000 }, // 1m
);
} catch (err) {
this.logger.error(err);
}
}
return createdWorkspace;
}
async addUserToWorkspace(
@ -386,4 +422,66 @@ export class WorkspaceService {
}
return { hostname: this.domainService.getUrl(hostname) };
}
async deleteUser(
authUser: User,
userId: string,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId);
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot delete yourself');
}
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
throw new BadRequestException('You cannot delete a user with owner role');
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{
name: 'Deleted user',
email: v4() + '@deleted.docmost.com',
avatarUrl: null,
settings: null,
deletedAt: new Date(),
},
userId,
workspaceId,
trx,
);
await trx.deleteFrom('groupUsers').where('userId', '=', userId).execute();
await trx
.deleteFrom('spaceMembers')
.where('userId', '=', userId)
.execute();
await trx
.deleteFrom('authAccounts')
.where('userId', '=', userId)
.execute();
});
try {
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
} catch (err) {
// empty
}
}
}

View File

@ -47,7 +47,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
log: (event: LogEvent) => {
if (environmentService.getNodeEnv() !== 'development') return;
const logger = new Logger(DatabaseModule.name);
if (event.level === 'query') {
if (event.level) {
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');

View File

@ -0,0 +1,12 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}"))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('pages').dropColumn('contributor_ids').execute();
}

View File

@ -18,6 +18,7 @@ export async function executeWithPagination<O, DB, TB extends keyof DB>(
perPage: number;
page: number;
experimental_deferredJoinPrimaryKey?: StringReference<DB, TB>;
hasEmptyIds?: boolean; // in cases where we pass empty whereIn ids
},
): Promise<PaginationResult<O>> {
if (opts.page < 1) {
@ -33,21 +34,20 @@ export async function executeWithPagination<O, DB, TB extends keyof DB>(
.select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey'))
.execute()
// @ts-expect-error TODO: Fix the type here later
.then((rows) => rows.map((row) => row.primaryKey));
qb = qb
.where((eb) =>
primaryKeys.length > 0
?
eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
? eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
: eb(sql`1`, '=', 0),
)
.clearOffset()
.clearLimit();
}
const rows = await qb.execute();
const rows = opts.hasEmptyIds ? [] : await qb.execute();
const hasNextPage = rows.length > 0 ? rows.length > opts.perPage : false;
const hasPrevPage = rows.length > 0 ? opts.page > 1 : false;

View File

@ -55,6 +55,18 @@ export class AttachmentRepo {
.execute();
}
updateAttachmentsByPageId(
updatableAttachment: UpdatableAttachment,
pageIds: string[],
trx?: KyselyTransaction,
) {
return dbOrTx(this.db, trx)
.updateTable('attachments')
.set(updatableAttachment)
.where('pageId', 'in', pageIds)
.executeTakeFirst();
}
async updateAttachment(
updatableAttachment: UpdatableAttachment,
attachmentId: string,

View File

@ -10,9 +10,9 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder } from 'kysely';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
@ -38,6 +38,7 @@ export class PageRepo {
'createdAt',
'updatedAt',
'deletedAt',
'contributorIds',
];
async findById(
@ -48,6 +49,7 @@ export class PageRepo {
includeSpace?: boolean;
includeCreator?: boolean;
includeLastUpdatedBy?: boolean;
includeContributors?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
@ -68,6 +70,10 @@ export class PageRepo {
query = query.select((eb) => this.withLastUpdatedBy(eb));
}
if (opts?.includeContributors) {
query = query.select((eb) => this.withContributors(eb));
}
if (opts?.includeSpace) {
query = query.select((eb) => this.withSpace(eb));
}
@ -90,18 +96,23 @@ export class PageRepo {
pageId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
let query = db
return this.updatePages(updatablePage, [pageId], trx);
}
async updatePages(
updatePageData: UpdatablePage,
pageIds: string[],
trx?: KyselyTransaction,
) {
return dbOrTx(this.db, trx)
.updateTable('pages')
.set({ ...updatablePage, updatedAt: new Date() });
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
return query.executeTakeFirst();
.set({ ...updatePageData, updatedAt: new Date() })
.where(
pageIds.some((pageId) => !isValidUUID(pageId)) ? 'slugId' : 'id',
'in',
pageIds,
)
.executeTakeFirst();
}
async insertPage(
@ -154,9 +165,11 @@ export class PageRepo {
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
@ -189,6 +202,15 @@ export class PageRepo {
).as('lastUpdatedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', sql`ANY(${eb.ref('pages.contributorIds')})`),
).as('contributors');
}
async getPageAndDescendants(parentPageId: string) {
return this.db
.withRecursive('page_hierarchy', (db) =>

View File

@ -114,6 +114,7 @@ export class SpaceMemberRepo {
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('spaceId', '=', spaceId)
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc')
.orderBy('spaceMembers.createdAt', 'asc');
if (pagination.query) {
@ -221,7 +222,7 @@ export class SpaceMemberRepo {
let query = this.db
.selectFrom('spaces')
.selectAll('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
@ -237,9 +238,12 @@ export class SpaceMemberRepo {
);
}
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;

View File

@ -139,6 +139,7 @@ export class UserRepo {
.selectFrom('users')
.select(this.baseFields)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'asc');
if (pagination.query) {

View File

@ -161,6 +161,7 @@ export interface PageHistory {
export interface Pages {
content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
creatorId: string | null;

View File

@ -21,7 +21,7 @@ import {
getProsemirrorContent,
PageExportTree,
replaceInternalLinks,
updateAttachmentUrls,
updateAttachmentUrlsToLocalPaths,
} from './utils';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Node } from '@tiptap/pm/model';
@ -193,7 +193,7 @@ export class ExportService {
if (includeAttachments) {
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
updatedJsonContent = updateAttachmentUrls(updatedJsonContent);
updatedJsonContent = updateAttachmentUrlsToLocalPaths(updatedJsonContent);
}
const pageTitle = getPageTitle(page.title);

View File

@ -79,16 +79,18 @@ function preserveDetail(turndownService: TurndownService) {
return node.nodeName === 'DETAILS';
},
replacement: function (content: any, node: HTMLInputElement) {
// TODO: preserve summary of nested details
const summary = node.querySelector(':scope > summary');
let detailSummary = '';
if (summary) {
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
summary.remove();
}
const detailsContent = turndownService.turndown(node.innerHTML);
const detailsContent = Array.from(node.childNodes)
.filter(child => child.nodeName !== 'SUMMARY')
.map(child => (child.nodeType === 1 ? turndownService.turndown((child as HTMLElement).outerHTML) : child.textContent))
.join('');
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
},
});

View File

@ -62,17 +62,30 @@ export function isAttachmentNode(nodeType: string) {
return attachmentNodeTypes.includes(nodeType);
}
export function updateAttachmentUrls(prosemirrorJson: any) {
export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
if (!doc) return null;
// Helper function to replace specific URL prefixes
const replacePrefix = (url: string): string => {
const prefixes = ['/files', '/api/files'];
for (const prefix of prefixes) {
if (url.startsWith(prefix)) {
return url.replace(prefix, 'files');
}
}
return url;
};
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.src && node.attrs.src.startsWith('/files')) {
//@ts-expect-error
node.attrs.src = node.attrs.src.replace('/files', 'files');
} else if (node.attrs.url && node.attrs.url.startsWith('/files')) {
//@ts-expect-error
node.attrs.url = node.attrs.url.replace('/files', 'files');
if (node.attrs.src) {
// @ts-ignore
node.attrs.src = replacePrefix(node.attrs.src);
}
if (node.attrs.url) {
// @ts-ignore
node.attrs.url = replacePrefix(node.attrs.url);
}
}
});

View File

@ -19,18 +19,27 @@ export class MailService {
async sendEmail(message: MailMessage): Promise<void> {
if (message.template) {
// in case this method is used directly. we do not send the tsx template from queue
message.html = await render(message.template, { pretty: true });
message.html = await render(message.template, {
pretty: true,
});
message.text = await render(message.template, { plainText: true });
}
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
let from = this.environmentService.getMailFromAddress();
if (message.from) {
from = message.from;
}
const sender = `${this.environmentService.getMailFromName()} <${from}> `;
await this.mailDriver.sendMail({ from: sender, ...message });
}
async sendToQueue(message: MailMessage): Promise<void> {
if (message.template) {
// transform the React object because it gets lost when sent via the queue
message.html = await render(message.template, { pretty: true });
message.html = await render(message.template, {
pretty: true,
});
message.text = await render(message.template, {
plainText: true,
});

View File

@ -11,7 +11,12 @@ export enum QueueJob {
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update',
DELETE_USER_AVATARS = 'delete-user-avatars',
PAGE_BACKLINKS = 'page-backlinks',
STRIPE_SEATS_SYNC = 'sync-stripe-seats',
TRIAL_ENDED = 'trial-ended',
WELCOME_EMAIL = 'welcome-email',
FIRST_PAYMENT_EMAIL = 'first-payment-email',
}

View File

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.9.0",
"version": "0.10.2",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@ -14,7 +14,7 @@
"client:dev": "nx run client:dev",
"server:dev": "nx run server:start:dev",
"server:start": "nx run server:start:prod",
"email:dev": "nx run @docmost/transactional:dev",
"email:dev": "nx run server:email:dev",
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
},
"dependencies": {

View File

@ -16,4 +16,4 @@ export * from "./lib/drawio";
export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/markdown";

View File

@ -78,10 +78,13 @@ export const Details = Node.create<DetailsOptions>({
dom.setAttribute("data-type", this.name);
btn.setAttribute("data-type", `${this.name}Button`);
div.setAttribute("data-type", `${this.name}Container`);
if (node.attrs.open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
if (editor.isEditable) {
if (node.attrs.open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
}
ico.innerHTML = icon("right-line");
@ -111,6 +114,7 @@ export const Details = Node.create<DetailsOptions>({
if (updatedNode.type !== this.type) {
return false;
}
if (!editor.isEditable) return true;
if (updatedNode.attrs.open) {
dom.setAttribute("open", "true");
} else {
@ -132,6 +136,10 @@ export const Details = Node.create<DetailsOptions>({
}
const slice = state.doc.slice(range.start, range.end);
if (slice.content.firstChild.type.name === "detailsSummary")
return false;
if (
!state.schema.nodes.detailsContent.contentMatch.matchFragment(
slice.content,

346
pnpm-lock.yaml generated
View File

@ -272,6 +272,9 @@ importers:
js-cookie:
specifier: ^3.0.5
version: 3.0.5
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
katex:
specifier: 0.16.21
version: 0.16.21
@ -483,9 +486,6 @@ importers:
cookie:
specifier: ^1.0.2
version: 1.0.2
fix-esm:
specifier: ^1.0.1
version: 1.0.1
fs-extra:
specifier: ^11.3.0
version: 11.3.0
@ -505,8 +505,8 @@ importers:
specifier: ^2.1.35
version: 2.1.35
nanoid:
specifier: ^5.1.0
version: 5.1.0
specifier: 3.3.11
version: 3.3.11
nestjs-kysely:
specifier: ^1.1.0
version: 1.1.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(kysely@0.27.5)(reflect-metadata@0.2.2)
@ -630,7 +630,7 @@ importers:
version: 7.0.0
ts-jest:
specifier: ^29.2.5
version: 29.2.5(@babel/core@7.24.3)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.3))(jest@29.7.0(@types/node@22.13.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3)
version: 29.2.5(@babel/core@7.24.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.5))(jest@29.7.0(@types/node@22.13.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3)
ts-loader:
specifier: ^9.5.2
version: 9.5.2(typescript@5.7.3)(webpack@5.98.0(@swc/core@1.5.25(@swc/helpers@0.5.5)))
@ -872,18 +872,10 @@ packages:
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.23.5':
resolution: {integrity: sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.26.2':
resolution: {integrity: sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==}
engines: {node: '>=6.9.0'}
'@babel/core@7.24.3':
resolution: {integrity: sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==}
engines: {node: '>=6.9.0'}
'@babel/core@7.24.5':
resolution: {integrity: sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==}
engines: {node: '>=6.9.0'}
@ -916,10 +908,6 @@ packages:
resolution: {integrity: sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.23.6':
resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.25.9':
resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==}
engines: {node: '>=6.9.0'}
@ -949,18 +937,10 @@ packages:
resolution: {integrity: sha512-Y50Cg3k0LKLMjxdPjIl40SdJgMB85iXn27Vk/qbHZCFx/o5XO3PSnpi675h1KEmmDb6OFArfd5SCQEQ5Q4H88g==}
engines: {node: '>=6.9.0'}
'@babel/helper-function-name@7.23.0':
resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==}
engines: {node: '>=6.9.0'}
'@babel/helper-function-name@7.24.6':
resolution: {integrity: sha512-xpeLqeeRkbxhnYimfr2PC+iA0Q7ljX/d1eZ9/inYbmfG2jpl8Lu3DyXvpOAnrS5kxkfOWJjioIMQsaMBXFI05w==}
engines: {node: '>=6.9.0'}
'@babel/helper-hoist-variables@7.22.5':
resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==}
engines: {node: '>=6.9.0'}
'@babel/helper-hoist-variables@7.24.6':
resolution: {integrity: sha512-SF/EMrC3OD7dSta1bLJIlrsVxwtd0UpjRJqLno6125epQMJ/kyFmpTT4pbvPbdQHzCHg+biQ7Syo8lnDtbR+uA==}
engines: {node: '>=6.9.0'}
@ -1071,10 +1051,6 @@ packages:
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.23.5':
resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.25.9':
resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==}
engines: {node: '>=6.9.0'}
@ -1083,10 +1059,6 @@ packages:
resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.24.1':
resolution: {integrity: sha512-BpU09QqEe6ZCHuIHFphEFgvNSrubve1FtyMton26ekZ85gRGi6LrTF7zArARp2YvyFxloeiRmtSCq5sjh1WqIg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.24.6':
resolution: {integrity: sha512-V2PI+NqnyFu1i0GyTd/O/cTpxzQCYioSkUIRmgo7gFEHKKCg5w46+r/A6WeUR1+P3TeQ49dspGPNd/E3n9AnnA==}
engines: {node: '>=6.9.0'}
@ -1099,11 +1071,6 @@ packages:
resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.24.1':
resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.24.5':
resolution: {integrity: sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==}
engines: {node: '>=6.0.0'}
@ -1143,13 +1110,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-proposal-export-namespace-from@7.18.9':
resolution: {integrity: sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==}
engines: {node: '>=6.9.0'}
deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-export-namespace-from instead.
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2':
resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==}
engines: {node: '>=6.9.0'}
@ -1615,14 +1575,6 @@ packages:
resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.22.15':
resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==}
engines: {node: '>=6.9.0'}
'@babel/template@7.24.0':
resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.24.6':
resolution: {integrity: sha512-3vgazJlLwNXi9jhrR1ef8qiB65L1RK90+lEQwv4OxveHnqC3BfmnHdgySwRLzf6akhlOYenT+b7AfWq+a//AHw==}
engines: {node: '>=6.9.0'}
@ -1631,10 +1583,6 @@ packages:
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.24.1':
resolution: {integrity: sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.25.9':
resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==}
engines: {node: '>=6.9.0'}
@ -4753,11 +4701,6 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.23.0:
resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.24.2:
resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@ -4819,9 +4762,6 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001600:
resolution: {integrity: sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==}
caniuse-lite@1.0.30001684:
resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==}
@ -5460,9 +5400,6 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
electron-to-chromium@1.4.715:
resolution: {integrity: sha512-XzWNH4ZSa9BwVUQSDorPWAUQ5WGuYz7zJUNpNif40zFCiCl20t8zgylmreNmn26h5kiyw2lg7RfTmeMBsDklqg==}
electron-to-chromium@1.5.65:
resolution: {integrity: sha512-PWVzBjghx7/wop6n22vS2MLU8tKGd4Q91aCEGhG/TYmW6PP5OcSXcdnxTe1NNt0T66N8D6jxh4kC8UsdzOGaIw==}
@ -5796,9 +5733,6 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fix-esm@1.0.1:
resolution: {integrity: sha512-EZtb7wPXZS54GaGxaWxMlhd1DUDCnAg5srlYdu/1ZVeW+7wwR3Tp59nu52dXByFs3MBRq+SByx1wDOJpRvLEXw==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@ -6612,6 +6546,10 @@ packages:
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
katex@0.16.21:
resolution: {integrity: sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==}
hasBin: true
@ -7013,21 +6951,16 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.0:
resolution: {integrity: sha512-zDAl/llz8Ue/EblwSYwdxGBYfj46IM1dhjVi8dyp9LQffoIGxJEAHj2oeZ4uNcgycSRcQ83CnfcZqEJzVDLcDw==}
engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@ -7097,9 +7030,6 @@ packages:
node-machine-id@1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
node-releases@2.0.18:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
@ -8646,12 +8576,6 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
update-browserslist-db@1.0.13:
resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
update-browserslist-db@1.1.1:
resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
hasBin: true
@ -9672,30 +9596,8 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.0.1
'@babel/compat-data@7.23.5': {}
'@babel/compat-data@7.26.2': {}
'@babel/core@7.24.3':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/code-frame': 7.24.2
'@babel/generator': 7.24.1
'@babel/helper-compilation-targets': 7.23.6
'@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.3)
'@babel/helpers': 7.24.1
'@babel/parser': 7.24.1
'@babel/template': 7.24.0
'@babel/traverse': 7.24.1
'@babel/types': 7.24.0
convert-source-map: 2.0.0
debug: 4.3.4
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/core@7.24.5':
dependencies:
'@ampproject/remapping': 2.3.0
@ -9786,14 +9688,6 @@ snapshots:
dependencies:
'@babel/types': 7.26.0
'@babel/helper-compilation-targets@7.23.6':
dependencies:
'@babel/compat-data': 7.23.5
'@babel/helper-validator-option': 7.23.5
browserslist: 4.23.0
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-compilation-targets@7.25.9':
dependencies:
'@babel/compat-data': 7.26.2
@ -9837,20 +9731,11 @@ snapshots:
'@babel/helper-environment-visitor@7.24.6': {}
'@babel/helper-function-name@7.23.0':
dependencies:
'@babel/template': 7.22.15
'@babel/types': 7.24.0
'@babel/helper-function-name@7.24.6':
dependencies:
'@babel/template': 7.24.6
'@babel/types': 7.24.6
'@babel/helper-hoist-variables@7.22.5':
dependencies:
'@babel/types': 7.24.0
'@babel/helper-hoist-variables@7.24.6':
dependencies:
'@babel/types': 7.24.6
@ -9874,15 +9759,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.23.3(@babel/core@7.24.3)':
dependencies:
'@babel/core': 7.24.3
'@babel/helper-environment-visitor': 7.22.20
'@babel/helper-module-imports': 7.22.15
'@babel/helper-simple-access': 7.22.5
'@babel/helper-split-export-declaration': 7.22.6
'@babel/helper-validator-identifier': 7.22.20
'@babel/helper-module-transforms@7.23.3(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
@ -9975,8 +9851,6 @@ snapshots:
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-option@7.23.5': {}
'@babel/helper-validator-option@7.25.9': {}
'@babel/helper-wrap-function@7.22.20':
@ -9985,14 +9859,6 @@ snapshots:
'@babel/template': 7.25.9
'@babel/types': 7.26.0
'@babel/helpers@7.24.1':
dependencies:
'@babel/template': 7.24.0
'@babel/traverse': 7.24.1
'@babel/types': 7.24.0
transitivePeerDependencies:
- supports-color
'@babel/helpers@7.24.6':
dependencies:
'@babel/template': 7.25.9
@ -10010,10 +9876,6 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.0.0
'@babel/parser@7.24.1':
dependencies:
'@babel/types': 7.24.0
'@babel/parser@7.24.5':
dependencies:
'@babel/types': 7.26.0
@ -10051,19 +9913,13 @@ snapshots:
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-decorators': 7.23.3(@babel/core@7.26.0)
'@babel/plugin-proposal-export-namespace-from@7.18.9(@babel/core@7.24.3)':
dependencies:
'@babel/core': 7.24.3
'@babel/helper-plugin-utils': 7.24.0
'@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.3)':
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10077,9 +9933,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.3)':
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10088,9 +9944,9 @@ snapshots:
'@babel/core': 7.24.6
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.3)':
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10119,11 +9975,6 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.24.3)':
dependencies:
'@babel/core': 7.24.3
'@babel/helper-plugin-utils': 7.24.0
'@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
@ -10139,9 +9990,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.3)':
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10155,9 +10006,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.3)':
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10181,9 +10032,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.3)':
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10197,9 +10048,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.3)':
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10213,9 +10064,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.3)':
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10229,9 +10080,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.3)':
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10245,9 +10096,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.3)':
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10261,9 +10112,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.3)':
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10282,9 +10133,9 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.3)':
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
optional: true
@ -10454,13 +10305,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.24.3)':
dependencies:
'@babel/core': 7.24.3
'@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.3)
'@babel/helper-plugin-utils': 7.24.0
'@babel/helper-simple-access': 7.22.5
'@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
@ -10763,18 +10607,6 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/template@7.22.15':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/parser': 7.26.2
'@babel/types': 7.24.0
'@babel/template@7.24.0':
dependencies:
'@babel/code-frame': 7.24.2
'@babel/parser': 7.24.1
'@babel/types': 7.24.0
'@babel/template@7.24.6':
dependencies:
'@babel/code-frame': 7.26.2
@ -10787,21 +10619,6 @@ snapshots:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/traverse@7.24.1':
dependencies:
'@babel/code-frame': 7.24.2
'@babel/generator': 7.24.1
'@babel/helper-environment-visitor': 7.22.20
'@babel/helper-function-name': 7.23.0
'@babel/helper-hoist-variables': 7.22.5
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.24.1
'@babel/types': 7.24.0
debug: 4.3.7
globals: 11.12.0
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.25.9':
dependencies:
'@babel/code-frame': 7.26.2
@ -13996,13 +13813,13 @@ snapshots:
transitivePeerDependencies:
- debug
babel-jest@29.7.0(@babel/core@7.24.3):
babel-jest@29.7.0(@babel/core@7.24.5):
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@jest/transform': 29.7.0
'@types/babel__core': 7.20.5
babel-plugin-istanbul: 6.1.1
babel-preset-jest: 29.6.3(@babel/core@7.24.3)
babel-preset-jest: 29.6.3(@babel/core@7.24.5)
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
@ -14086,21 +13903,21 @@ snapshots:
optionalDependencies:
'@babel/traverse': 7.25.9
babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.3):
babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.5):
dependencies:
'@babel/core': 7.24.3
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.3)
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.3)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.3)
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.3)
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.3)
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.3)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.3)
'@babel/core': 7.24.5
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.5)
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.5)
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.5)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.5)
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.5)
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.5)
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.5)
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.5)
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.5)
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.5)
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.5)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.5)
optional: true
babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.6):
@ -14119,11 +13936,11 @@ snapshots:
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.6)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.6)
babel-preset-jest@29.6.3(@babel/core@7.24.3):
babel-preset-jest@29.6.3(@babel/core@7.24.5):
dependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.3)
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.5)
optional: true
babel-preset-jest@29.6.3(@babel/core@7.24.6):
@ -14188,13 +14005,6 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.23.0:
dependencies:
caniuse-lite: 1.0.30001600
electron-to-chromium: 1.4.715
node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.23.0)
browserslist@4.24.2:
dependencies:
caniuse-lite: 1.0.30001684
@ -14266,8 +14076,6 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001600: {}
caniuse-lite@1.0.30001684: {}
chalk@2.4.2:
@ -14914,8 +14722,6 @@ snapshots:
dependencies:
jake: 10.8.7
electron-to-chromium@1.4.715: {}
electron-to-chromium@1.5.65: {}
emittery@0.13.1: {}
@ -15469,14 +15275,6 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
fix-esm@1.0.1:
dependencies:
'@babel/core': 7.24.3
'@babel/plugin-proposal-export-namespace-from': 7.18.9(@babel/core@7.24.3)
'@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.24.3)
transitivePeerDependencies:
- supports-color
flat-cache@4.0.1:
dependencies:
flatted: 3.2.9
@ -16534,6 +16332,8 @@ snapshots:
jwa: 1.4.1
safe-buffer: 5.2.1
jwt-decode@4.0.0: {}
katex@0.16.21:
dependencies:
commander: 8.3.0
@ -16904,12 +16704,10 @@ snapshots:
mute-stream@2.0.0: {}
nanoid@3.3.11: {}
nanoid@3.3.7: {}
nanoid@3.3.8: {}
nanoid@5.1.0: {}
natural-compare@1.4.0: {}
needle@3.3.1:
@ -16974,8 +16772,6 @@ snapshots:
node-machine-id@1.1.12: {}
node-releases@2.0.14: {}
node-releases@2.0.18: {}
nodemailer@6.10.0: {}
@ -17415,7 +17211,7 @@ snapshots:
postcss@8.4.31:
dependencies:
nanoid: 3.3.8
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
@ -17427,7 +17223,7 @@ snapshots:
postcss@8.5.2:
dependencies:
nanoid: 3.3.8
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
@ -18477,7 +18273,7 @@ snapshots:
ts-dedent@2.2.0: {}
ts-jest@29.2.5(@babel/core@7.24.3)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.3))(jest@29.7.0(@types/node@22.13.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3):
ts-jest@29.2.5(@babel/core@7.24.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.5))(jest@29.7.0(@types/node@22.13.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@ -18491,10 +18287,10 @@ snapshots:
typescript: 5.7.3
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.24.3
'@babel/core': 7.24.5
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.24.3)
babel-jest: 29.7.0(@babel/core@7.24.5)
ts-loader@9.5.2(typescript@5.7.3)(webpack@5.98.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
dependencies:
@ -18678,12 +18474,6 @@ snapshots:
universalify@2.0.1: {}
update-browserslist-db@1.0.13(browserslist@4.23.0):
dependencies:
browserslist: 4.23.0
escalade: 3.1.1
picocolors: 1.0.0
update-browserslist-db@1.1.1(browserslist@4.24.2):
dependencies:
browserslist: 4.24.2