mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 09:12:36 +10:00
Compare commits
1 Commits
fix/editor
...
new-cloud-
| Author | SHA1 | Date | |
|---|---|---|---|
| fa398e7d54 |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.0",
|
"version": "0.20.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -15,47 +15,44 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
"@excalidraw/excalidraw": "^0.17.6",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^7.17.0",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^7.17.0",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^7.17.0",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^7.17.0",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"@tiptap/extension-character-count": "^2.10.3",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
"alfaaz": "^1.1.0",
|
"axios": "^1.8.4",
|
||||||
"axios": "^1.9.0",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"highlightjs-sap-abap": "^0.3.0",
|
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.1",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "0.16.22",
|
"katex": "0.16.21",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.2.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.4.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.15",
|
"react-clear-modal": "^2.0.11",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^1.0.1",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.56"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
@ -79,6 +76,6 @@
|
|||||||
"prettier": "^3.4.1",
|
"prettier": "^3.4.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.17.0",
|
"typescript-eslint": "^8.17.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
||||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||||
"Share not found": "Freigabe nicht gefunden",
|
"Share not found": "Freigabe nicht gefunden",
|
||||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
"Failed to share page": "Fehler beim Teilen der Seite"
|
||||||
"Copy page": "Seite kopieren",
|
|
||||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
|
||||||
"Page copied successfully": "Seite erfolgreich kopiert"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -354,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||||
"New update": "New update",
|
"New update": "New update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
"Default page edit mode": "Default page edit mode",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
|
||||||
"Reading": "Reading"
|
|
||||||
"Delete member": "Delete member",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"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.",
|
"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.",
|
||||||
@ -387,17 +384,7 @@
|
|||||||
"Share deleted successfully": "Share deleted successfully",
|
"Share deleted successfully": "Share deleted successfully",
|
||||||
"Share not found": "Share not found",
|
"Share not found": "Share not found",
|
||||||
"Failed to share page": "Failed to share page",
|
"Failed to share page": "Failed to share page",
|
||||||
"Copy page": "Copy page",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Page copied successfully",
|
"Page copied successfully": "Page copied successfully"
|
||||||
"Find": "Find",
|
|
||||||
"Not found": "Not found",
|
|
||||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
|
||||||
"Next match (Enter)": "Next match (Enter)",
|
|
||||||
"Match case (Alt+C)": "Match case (Alt+C)",
|
|
||||||
"Replace": "Replace",
|
|
||||||
"Close (Escape)": "Close (Escape)",
|
|
||||||
"Replace (Enter)": "Replace (Enter)",
|
|
||||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
|
||||||
"Replace all": "Replace all"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
||||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||||
"Share not found": "Compartición no encontrada",
|
"Share not found": "Compartición no encontrada",
|
||||||
"Failed to share page": "Error al compartir la página",
|
"Failed to share page": "Error al compartir la página"
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
||||||
"Share deleted successfully": "Partage supprimé avec succès",
|
"Share deleted successfully": "Partage supprimé avec succès",
|
||||||
"Share not found": "Partage non trouvé",
|
"Share not found": "Partage non trouvé",
|
||||||
"Failed to share page": "Échec du partage de la page",
|
"Failed to share page": "Échec du partage de la page"
|
||||||
"Copy page": "Copier la page",
|
|
||||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
|
||||||
"Page copied successfully": "Page copiée avec succès"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
||||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
"Share deleted successfully": "Condivisione eliminata con successo",
|
||||||
"Share not found": "Condivisione non trovata",
|
"Share not found": "Condivisione non trovata",
|
||||||
"Failed to share page": "Condivisione della pagina fallita",
|
"Failed to share page": "Condivisione della pagina fallita"
|
||||||
"Copy page": "Copia pagina",
|
|
||||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
|
||||||
"Page copied successfully": "Pagina copiata con successo"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -347,7 +347,7 @@
|
|||||||
"Members added successfully": "メンバーを追加しました",
|
"Members added successfully": "メンバーを追加しました",
|
||||||
"Member removed successfully": "メンバーが削除されました",
|
"Member removed successfully": "メンバーが削除されました",
|
||||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||||
"Share deleted successfully": "共有が正常に削除されました",
|
"Share deleted successfully": "共有が正常に削除されました",
|
||||||
"Share not found": "共有が見つかりません",
|
"Share not found": "共有が見つかりません",
|
||||||
"Failed to share page": "ページの共有に失敗しました",
|
"Failed to share page": "ページの共有に失敗しました"
|
||||||
"Copy page": "ページをコピー",
|
|
||||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
|
||||||
"Page copied successfully": "ページのコピーに成功しました"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||||
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||||
"Enter your current password": "기존 비밀번호를 입력하세요",
|
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||||
"enter your full name": "전체 이름을 입력하세요",
|
"enter your full name": "전체 이름을 입력하세요",
|
||||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||||
@ -170,7 +170,7 @@
|
|||||||
"Successfully restored": "복원 완료",
|
"Successfully restored": "복원 완료",
|
||||||
"System settings": "시스템 설정",
|
"System settings": "시스템 설정",
|
||||||
"Theme": "배경",
|
"Theme": "배경",
|
||||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
|
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||||
"Toggle full page width": "전체 페이지 너비 전환",
|
"Toggle full page width": "전체 페이지 너비 전환",
|
||||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||||
"untitled": "제목 없음",
|
"untitled": "제목 없음",
|
||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
||||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||||
"Share not found": "공유를 찾을 수 없습니다",
|
"Share not found": "공유를 찾을 수 없습니다",
|
||||||
"Failed to share page": "페이지 공유에 실패했습니다",
|
"Failed to share page": "페이지 공유에 실패했습니다"
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
||||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||||
"Share not found": "Delen niet gevonden",
|
"Share not found": "Delen niet gevonden",
|
||||||
"Failed to share page": "Pagina delen mislukt",
|
"Failed to share page": "Pagina delen mislukt"
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
||||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||||
"Share not found": "Compartilhamento não encontrado",
|
"Share not found": "Compartilhamento não encontrado",
|
||||||
"Failed to share page": "Falha ao compartilhar página",
|
"Failed to share page": "Falha ao compartilhar página"
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
||||||
"Share deleted successfully": "Общий доступ успешно удален",
|
"Share deleted successfully": "Общий доступ успешно удален",
|
||||||
"Share not found": "Общий доступ не найден",
|
"Share not found": "Общий доступ не найден",
|
||||||
"Failed to share page": "Не удалось поделиться страницей",
|
"Failed to share page": "Не удалось поделиться страницей"
|
||||||
"Copy page": "Копировать страницу",
|
|
||||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
|
||||||
"Page copied successfully": "Страница успешно скопирована"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,390 +0,0 @@
|
|||||||
{
|
|
||||||
"Account": "Обліковий запис",
|
|
||||||
"Active": "Активний",
|
|
||||||
"Add": "Додати",
|
|
||||||
"Add group members": "Додати учасників групи",
|
|
||||||
"Add groups": "Додати групи",
|
|
||||||
"Add members": "Додати учасників",
|
|
||||||
"Add to groups": "Додати до груп",
|
|
||||||
"Add space members": "Додати учасників простору",
|
|
||||||
"Admin": "Адміністратор",
|
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
|
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
|
|
||||||
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
|
|
||||||
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
|
|
||||||
"Can edit": "Може редагувати",
|
|
||||||
"Can manage workspace": "Може керувати робочою областю",
|
|
||||||
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
|
|
||||||
"Can view": "Може переглядати",
|
|
||||||
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
|
|
||||||
"Cancel": "Скасувати",
|
|
||||||
"Change email": "Змінити електронну пошту",
|
|
||||||
"Change password": "Змінити пароль",
|
|
||||||
"Change photo": "Змінити фото",
|
|
||||||
"Choose a role": "Оберіть роль",
|
|
||||||
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
|
|
||||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
|
||||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
|
||||||
"Confirm": "Підтвердити",
|
|
||||||
"Copy link": "Копіювати посилання",
|
|
||||||
"Create": "Створити",
|
|
||||||
"Create group": "Створити групу",
|
|
||||||
"Create page": "Створити сторінку",
|
|
||||||
"Create space": "Створити простір",
|
|
||||||
"Create workspace": "Створити робочу область",
|
|
||||||
"Current password": "Поточний пароль",
|
|
||||||
"Dark": "Темна",
|
|
||||||
"Date": "Дата",
|
|
||||||
"Delete": "Видалити",
|
|
||||||
"Delete group": "Видалити групу",
|
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
|
|
||||||
"Description": "Опис",
|
|
||||||
"Details": "Деталі",
|
|
||||||
"e.g ACME": "наприклад, ACME",
|
|
||||||
"e.g ACME Inc": "наприклад, ACME Inc",
|
|
||||||
"e.g Developers": "наприклад, Розробники",
|
|
||||||
"e.g Group for developers": "наприклад, Група для розробників",
|
|
||||||
"e.g product": "наприклад, продукт",
|
|
||||||
"e.g Product Team": "наприклад, Продуктова команда",
|
|
||||||
"e.g Sales": "наприклад, Продажі",
|
|
||||||
"e.g Space for product team": "наприклад, Простір для продуктової команди",
|
|
||||||
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
|
|
||||||
"Edit": "Редагувати",
|
|
||||||
"Edit group": "Редагувати групу",
|
|
||||||
"Email": "Електронна пошта",
|
|
||||||
"Enter a strong password": "Введіть надійний пароль",
|
|
||||||
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
|
|
||||||
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
|
|
||||||
"Enter your current password": "Введіть ваш поточний пароль",
|
|
||||||
"enter your full name": "введіть ваше повне ім'я",
|
|
||||||
"Enter your new password": "Введіть ваш новий пароль",
|
|
||||||
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
|
|
||||||
"Enter your password": "Введіть ваш пароль",
|
|
||||||
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
|
|
||||||
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
|
|
||||||
"Export": "Експорт",
|
|
||||||
"Failed to create page": "Не вдалося створити сторінку",
|
|
||||||
"Failed to delete page": "Не вдалося видалити сторінку",
|
|
||||||
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
|
|
||||||
"Failed to import pages": "Не вдалося імпортувати сторінки",
|
|
||||||
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
|
|
||||||
"Failed to update data": "Не вдалося оновити дані",
|
|
||||||
"Full access": "Повний доступ",
|
|
||||||
"Full page width": "Ширина на всю сторінку",
|
|
||||||
"Full width": "На всю ширину",
|
|
||||||
"General": "Загальні",
|
|
||||||
"Group": "Група",
|
|
||||||
"Group description": "Опис групи",
|
|
||||||
"Group name": "Назва групи",
|
|
||||||
"Groups": "Групи",
|
|
||||||
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
|
|
||||||
"Home": "Головна",
|
|
||||||
"Import pages": "Імпорт сторінок",
|
|
||||||
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
|
|
||||||
"Importing pages": "Імпортування сторінок",
|
|
||||||
"invalid invitation link": "посилання на запрошення недійсне",
|
|
||||||
"Invitation signup": "Реєстрація за запрошенням",
|
|
||||||
"Invite by email": "Запросити електронною поштою",
|
|
||||||
"Invite members": "Запросити учасників",
|
|
||||||
"Invite new members": "Запросити нових учасників",
|
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
|
|
||||||
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
|
|
||||||
"Join the workspace": "Приєднатися до робочої області",
|
|
||||||
"Language": "Мова",
|
|
||||||
"Light": "Світла",
|
|
||||||
"Link copied": "Посилання скопійовано",
|
|
||||||
"Login": "Увійти",
|
|
||||||
"Logout": "Вийти",
|
|
||||||
"Manage Group": "Керування групою",
|
|
||||||
"Manage members": "Керування учасниками",
|
|
||||||
"member": "учасник",
|
|
||||||
"Member": "Учасник",
|
|
||||||
"members": "учасники",
|
|
||||||
"Members": "Учасники",
|
|
||||||
"My preferences": "Мої налаштування",
|
|
||||||
"My Profile": "Мій профіль",
|
|
||||||
"My profile": "Мій профіль",
|
|
||||||
"Name": "Ім'я",
|
|
||||||
"New email": "Нова електронна адреса",
|
|
||||||
"New page": "Нова сторінка",
|
|
||||||
"New password": "Новий пароль",
|
|
||||||
"No group found": "Групу не знайдено",
|
|
||||||
"No page history saved yet.": "Історія сторінок ще не збережена.",
|
|
||||||
"No pages yet": "Сторінок поки немає",
|
|
||||||
"No results found...": "Результати не знайдено...",
|
|
||||||
"No user found": "Користувача не знайдено",
|
|
||||||
"Overview": "Огляд",
|
|
||||||
"Owner": "Власник",
|
|
||||||
"page": "сторінка",
|
|
||||||
"Page deleted successfully": "Сторінку успішно видалено",
|
|
||||||
"Page history": "Історія сторінки",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
|
||||||
"Pages": "Сторінки",
|
|
||||||
"pages": "сторінки",
|
|
||||||
"Password": "Пароль",
|
|
||||||
"Password changed successfully": "Пароль успішно змінено",
|
|
||||||
"Pending": "В очікуванні",
|
|
||||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
|
||||||
"Preferences": "Налаштування",
|
|
||||||
"Print PDF": "Друк PDF",
|
|
||||||
"Profile": "Профіль",
|
|
||||||
"Recently updated": "Нещодавно оновлено",
|
|
||||||
"Remove": "Видалити",
|
|
||||||
"Remove group member": "Видалити учасника групи",
|
|
||||||
"Remove space member": "Видалити учасника простору",
|
|
||||||
"Restore": "Відновити",
|
|
||||||
"Role": "Роль",
|
|
||||||
"Save": "Зберегти",
|
|
||||||
"Search": "Пошук",
|
|
||||||
"Search for groups": "Пошук груп",
|
|
||||||
"Search for users": "Пошук користувачів",
|
|
||||||
"Search for users and groups": "Пошук користувачів та груп",
|
|
||||||
"Search...": "Пошук...",
|
|
||||||
"Select language": "Оберіть мову",
|
|
||||||
"Select role": "Оберіть роль",
|
|
||||||
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
|
|
||||||
"Select theme": "Оберіть тему",
|
|
||||||
"Send invitation": "Надіслати запрошення",
|
|
||||||
"Invitation sent": "Запрошення надіслано",
|
|
||||||
"Settings": "Налаштування",
|
|
||||||
"Setup workspace": "Налаштувати робочу область",
|
|
||||||
"Sign In": "Вхід",
|
|
||||||
"Sign Up": "Реєстрація",
|
|
||||||
"Slug": "Slug",
|
|
||||||
"Space": "Простір",
|
|
||||||
"Space description": "Опис простору",
|
|
||||||
"Space menu": "Меню простору",
|
|
||||||
"Space name": "Назва простору",
|
|
||||||
"Space settings": "Налаштування простору",
|
|
||||||
"Space slug": "Slug простору",
|
|
||||||
"Spaces": "Простори",
|
|
||||||
"Spaces you belong to": "Простори, до яких ви належите",
|
|
||||||
"No space found": "Простори не знайдено",
|
|
||||||
"Search for spaces": "Пошук просторів",
|
|
||||||
"Start typing to search...": "Почніть вводити для пошуку...",
|
|
||||||
"Status": "Статус",
|
|
||||||
"Successfully imported": "Успішно імпортовано",
|
|
||||||
"Successfully restored": "Успішно відновлено",
|
|
||||||
"System settings": "Системні налаштування",
|
|
||||||
"Theme": "Тема",
|
|
||||||
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
|
|
||||||
"Toggle full page width": "Перемкнути ширину на всю сторінку",
|
|
||||||
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
|
|
||||||
"untitled": "без назви",
|
|
||||||
"Untitled": "Без назви",
|
|
||||||
"Updated successfully": "Оновлено успішно",
|
|
||||||
"User": "Користувач",
|
|
||||||
"Workspace": "Робоча область",
|
|
||||||
"Workspace Name": "Ім'я робочої області",
|
|
||||||
"Workspace settings": "Налаштування робочої області",
|
|
||||||
"You can change your password here.": "Ви можете змінити свій пароль тут.",
|
|
||||||
"Your Email": "Ваша електронна пошта",
|
|
||||||
"Your import is complete.": "Ваш імпорт завершено.",
|
|
||||||
"Your name": "Ваше ім'я",
|
|
||||||
"Your Name": "Ваше ім'я",
|
|
||||||
"Your password": "Ваш пароль",
|
|
||||||
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
|
|
||||||
"Sidebar toggle": "Перемкнути бічну панель",
|
|
||||||
"Comments": "Коментарі",
|
|
||||||
"404 page not found": "404 сторінку не знайдено",
|
|
||||||
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
|
|
||||||
"Take me back to homepage": "Повернутися на головну сторінку",
|
|
||||||
"Forgot password": "Забули пароль",
|
|
||||||
"Forgot your password?": "Забули пароль?",
|
|
||||||
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
|
|
||||||
"Send reset link": "Надіслати посилання для скидання",
|
|
||||||
"Password reset": "Скидання пароля",
|
|
||||||
"Your new password": "Ваш новий пароль",
|
|
||||||
"Set password": "Встановити пароль",
|
|
||||||
"Write a comment": "Написати коментар",
|
|
||||||
"Reply...": "Відповісти...",
|
|
||||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
|
||||||
"No comments yet.": "Коментарів поки немає.",
|
|
||||||
"Edit comment": "Редагувати коментар",
|
|
||||||
"Delete comment": "Видалити коментар",
|
|
||||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
|
||||||
"Comment created successfully": "Коментар успішно створено",
|
|
||||||
"Error creating comment": "Помилка при створенні коментаря",
|
|
||||||
"Comment updated successfully": "Коментар успішно оновлено",
|
|
||||||
"Failed to update comment": "Не вдалося оновити коментар",
|
|
||||||
"Comment deleted successfully": "Коментар успішно видалено",
|
|
||||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
|
||||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
|
||||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
|
||||||
"Revoke invitation": "Відкликати запрошення",
|
|
||||||
"Revoke": "Відкликати",
|
|
||||||
"Don't": "Ні",
|
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
|
|
||||||
"Resend invitation": "Надіслати запрошення повторно",
|
|
||||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
|
||||||
"Invite link": "Посилання для запрошення",
|
|
||||||
"Copy": "Копіювати",
|
|
||||||
"Copied": "Скопійовано",
|
|
||||||
"Select a user": "Оберіть користувача",
|
|
||||||
"Select a group": "Оберіть групу",
|
|
||||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
|
||||||
"Delete space": "Видалити простір",
|
|
||||||
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
|
|
||||||
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
|
|
||||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
|
|
||||||
"Confirm space name": "Підтвердіть назву простору",
|
|
||||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
|
|
||||||
"Format": "Формат",
|
|
||||||
"Include subpages": "Включити вкладені сторінки",
|
|
||||||
"Include attachments": "Включити вкладення",
|
|
||||||
"Select export format": "Виберіть формат експорту",
|
|
||||||
"Export failed:": "Експортування не вдалося:",
|
|
||||||
"export error": "помилка експорту",
|
|
||||||
"Export page": "Експорт сторінки",
|
|
||||||
"Export space": "Експорт простору",
|
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
|
||||||
"Align left": "По лівому краю",
|
|
||||||
"Align right": "По правому краю",
|
|
||||||
"Align center": "По центру",
|
|
||||||
"Justify": "По ширині",
|
|
||||||
"Merge cells": "Об'єднати комірки",
|
|
||||||
"Split cell": "Розділити комірку",
|
|
||||||
"Delete column": "Видалити стовпець",
|
|
||||||
"Delete row": "Видалити рядок",
|
|
||||||
"Add left column": "Додати стовпець ліворуч",
|
|
||||||
"Add right column": "Додати стовпець праворуч",
|
|
||||||
"Add row above": "Додати рядок вище",
|
|
||||||
"Add row below": "Додати рядок нижче",
|
|
||||||
"Delete table": "Видалити таблицю",
|
|
||||||
"Info": "Інформація",
|
|
||||||
"Success": "Успішно",
|
|
||||||
"Warning": "Попередження",
|
|
||||||
"Danger": "Важливо",
|
|
||||||
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
|
|
||||||
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
|
|
||||||
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
|
|
||||||
"Exit": "Вийти",
|
|
||||||
"Save & Exit": "Зберегти та вийти",
|
|
||||||
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
|
|
||||||
"Paste link": "Вставити посилання",
|
|
||||||
"Edit link": "Редагувати посилання",
|
|
||||||
"Remove link": "Видалити посилання",
|
|
||||||
"Add link": "Додати посилання",
|
|
||||||
"Please enter a valid url": "Будь ласка, введіть коректний url",
|
|
||||||
"Empty equation": "Порожнє рівняння",
|
|
||||||
"Invalid equation": "Неприпустиме рівняння",
|
|
||||||
"Color": "Колір",
|
|
||||||
"Text color": "Колір тексту",
|
|
||||||
"Default": "За замовчуванням",
|
|
||||||
"Blue": "Синій",
|
|
||||||
"Green": "Зелений",
|
|
||||||
"Purple": "Фіолетовий",
|
|
||||||
"Red": "Червоний",
|
|
||||||
"Yellow": "Жовтий",
|
|
||||||
"Orange": "Помаранчевий",
|
|
||||||
"Pink": "Рожевий",
|
|
||||||
"Gray": "Сірий",
|
|
||||||
"Embed link": "Вбудоване посилання",
|
|
||||||
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
|
|
||||||
"Embed {{provider}}": "Вбудувати {{provider}}",
|
|
||||||
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
|
|
||||||
"Bold": "Жирний",
|
|
||||||
"Italic": "Курсив",
|
|
||||||
"Underline": "Підкреслений",
|
|
||||||
"Strike": "Закреслений",
|
|
||||||
"Code": "Код",
|
|
||||||
"Comment": "Коментар",
|
|
||||||
"Text": "Текст",
|
|
||||||
"Heading 1": "Заголовок 1",
|
|
||||||
"Heading 2": "Заголовок 2",
|
|
||||||
"Heading 3": "Заголовок 3",
|
|
||||||
"To-do List": "Список справ",
|
|
||||||
"Bullet List": "Маркований список",
|
|
||||||
"Numbered List": "Нумерований список",
|
|
||||||
"Blockquote": "Блок цитування",
|
|
||||||
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
|
|
||||||
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
|
|
||||||
"Big section heading.": "Великий заголовок розділу.",
|
|
||||||
"Medium section heading.": "Середній заголовок розділу.",
|
|
||||||
"Small section heading.": "Малий заголовок розділу.",
|
|
||||||
"Create a simple bullet list.": "Створити простий маркований список.",
|
|
||||||
"Create a list with numbering.": "Створити нумерований список.",
|
|
||||||
"Create block quote.": "Створити блок цитування.",
|
|
||||||
"Insert code snippet.": "Вставити фрагмент коду.",
|
|
||||||
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
|
|
||||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
|
||||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
|
||||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
|
||||||
"Table": "Таблиця",
|
|
||||||
"Insert a table.": "Вставити таблицю.",
|
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
|
||||||
"Video": "Відео",
|
|
||||||
"Divider": "Роздільник",
|
|
||||||
"Quote": "Цитата",
|
|
||||||
"Image": "Зображення",
|
|
||||||
"File attachment": "Прикріплений файл",
|
|
||||||
"Toggle block": "Блок, що згортається",
|
|
||||||
"Callout": "Виноска",
|
|
||||||
"Insert callout notice.": "Вставити виноску з повідомленням.",
|
|
||||||
"Math inline": "Формула",
|
|
||||||
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
|
|
||||||
"Math block": "Блок формул",
|
|
||||||
"Insert math equation": "Вставити математичне рівняння",
|
|
||||||
"Mermaid diagram": "Діаграма Mermaid",
|
|
||||||
"Insert mermaid diagram": "Вставити діаграму Mermaid",
|
|
||||||
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
|
|
||||||
"Insert current date": "Вставити поточну дату",
|
|
||||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
|
||||||
"Multiple": "Декілька",
|
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
|
||||||
"Toggle title": "Перемкнути заголовок",
|
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
|
||||||
"Names do not match": "Назви не співпадають",
|
|
||||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
|
||||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
|
||||||
"Space created successfully": "Простір успішно створено",
|
|
||||||
"Space updated successfully": "Простір успішно оновлено",
|
|
||||||
"Space deleted successfully": "Простір успішно видалено",
|
|
||||||
"Members added successfully": "Учасників успішно додано",
|
|
||||||
"Member removed successfully": "Учасника успішно видалено",
|
|
||||||
"Member role updated successfully": "Роль учасника успішно оновлено",
|
|
||||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
|
||||||
"Created at: {{time}}": "Дата створення: {{time}}",
|
|
||||||
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
|
|
||||||
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
|
|
||||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
|
||||||
"New update": "Нове оновлення",
|
|
||||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
|
||||||
"Delete member": "Видалити учасника",
|
|
||||||
"Member deleted successfully": "Учасника успішно видалено",
|
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
|
||||||
"Move": "Перемістити",
|
|
||||||
"Move page": "Перемістити сторінку",
|
|
||||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
|
||||||
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
|
|
||||||
"Table of contents": "Зміст",
|
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
|
|
||||||
"Share": "Поділитися",
|
|
||||||
"Public sharing": "Публічний доступ",
|
|
||||||
"Shared by": "Поділився",
|
|
||||||
"Shared at": "Поділився в",
|
|
||||||
"Inherits public sharing from": "Успадковує публічний доступ від",
|
|
||||||
"Share to web": "Поділитися в інтернеті",
|
|
||||||
"Shared to web": "Розміщено в інтернеті",
|
|
||||||
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
|
|
||||||
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
|
|
||||||
"Include sub-pages": "Включити підсторінки",
|
|
||||||
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
|
|
||||||
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
|
|
||||||
"Open page": "Відкрити сторінку",
|
|
||||||
"Page": "Сторінка",
|
|
||||||
"Delete public share link": "Видалити посилання на публічний доступ",
|
|
||||||
"Delete share": "Видалити спільний доступ",
|
|
||||||
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
|
|
||||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
|
||||||
"Share not found": "Спільний доступ не знайдено",
|
|
||||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
|
||||||
"Copy page": "Копіювати сторінки",
|
|
||||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
|
||||||
"Page copied successfully": "Сторінку успішно скопійовано"
|
|
||||||
}
|
|
||||||
@ -383,8 +383,5 @@
|
|||||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
||||||
"Share deleted successfully": "分享已成功删除",
|
"Share deleted successfully": "分享已成功删除",
|
||||||
"Share not found": "未找到分享",
|
"Share not found": "未找到分享",
|
||||||
"Failed to share page": "页面分享失败",
|
"Failed to share page": "页面分享失败"
|
||||||
"Copy page": "复制页面",
|
|
||||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
|
||||||
"Page copied successfully": "页面复制成功"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import { rem } from "@mantine/core";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
size?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfluenceIcon({ size }: Props) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ width: rem(size), height: rem(size) }}
|
|
||||||
>
|
|
||||||
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
@ -10,7 +8,6 @@ export default function Layout() {
|
|||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
{isCloud() && <PosthogUser />}
|
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
|||||||
>
|
>
|
||||||
Plan
|
Plan
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} fz="lg" tt="capitalize">
|
<Text fw={700} fz="lg">
|
||||||
{plans.find(
|
{
|
||||||
(plan) => plan.productId === billing.stripeProductId,
|
plans.find(
|
||||||
)?.name ||
|
(plan) => plan.productId === billing.stripeProductId,
|
||||||
billing.planName ||
|
)?.name
|
||||||
"Standard"}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@ -112,58 +112,18 @@ export default function BillingDetails() {
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
className={classes.label}
|
className={classes.label}
|
||||||
>
|
>
|
||||||
Cost
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} fz="lg">
|
||||||
|
{(billing.amount / 100) * billing.quantity}{" "}
|
||||||
|
{billing.currency.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" fz="sm">
|
||||||
|
${billing.amount / 100} /user/{billing.interval}
|
||||||
</Text>
|
</Text>
|
||||||
{billing.billingScheme === "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
${billing.amount / 100} {billing.currency.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
per {billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{billing.billingScheme !== "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
|
||||||
{billing.currency.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Current Tier
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
For {billing.tieredUpTo} users
|
|
||||||
</Text>
|
|
||||||
{/*billing.tieredFlatAmount && (
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
</Text>
|
|
||||||
)*/}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,28 +2,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
List,
|
List,
|
||||||
|
SegmentedControl,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Select,
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Badge,
|
|
||||||
Flex,
|
|
||||||
Switch,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||||
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
|
|
||||||
export default function BillingPlans() {
|
export default function BillingPlans() {
|
||||||
const { data: plans } = useBillingPlans();
|
const { data: plans } = useBillingPlans();
|
||||||
const [isAnnual, setIsAnnual] = useState(true);
|
const [interval, setInterval] = useState("yearly");
|
||||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
|
||||||
null,
|
if (!plans) {
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleCheckout = async (priceId: string) => {
|
const handleCheckout = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -36,153 +32,84 @@ export default function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPlan = plans[0];
|
|
||||||
|
|
||||||
// Set initial tier value if not set
|
|
||||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
|
||||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTierValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectData = firstPlan.pricingTiers
|
|
||||||
.filter((tier) => !tier.custom)
|
|
||||||
.map((tier, index) => {
|
|
||||||
const prevMaxUsers =
|
|
||||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
|
||||||
return {
|
|
||||||
value: tier.upTo.toString(),
|
|
||||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Group justify="center" p="xl">
|
||||||
{/* Controls Section */}
|
{plans.map((plan) => {
|
||||||
<Stack gap="xl" mb="md">
|
const price =
|
||||||
{/* Team Size and Billing Controls */}
|
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||||
<Group justify="center" align="center" gap="sm">
|
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||||
<Select
|
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||||
label="Team size"
|
|
||||||
description="Select the number of users"
|
|
||||||
value={selectedTierValue}
|
|
||||||
onChange={setSelectedTierValue}
|
|
||||||
data={selectData}
|
|
||||||
w={250}
|
|
||||||
size="md"
|
|
||||||
allowDeselect={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="center" align="start">
|
return (
|
||||||
<Flex justify="center" gap="md" align="center">
|
<Card
|
||||||
<Text size="md">Monthly</Text>
|
key={plan.name}
|
||||||
<Switch
|
withBorder
|
||||||
defaultChecked={isAnnual}
|
radius="md"
|
||||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
shadow="sm"
|
||||||
size="sm"
|
p="xl"
|
||||||
/>
|
w={300}
|
||||||
<Text size="md">
|
>
|
||||||
Annually
|
<SegmentedControl
|
||||||
<Badge component="span" variant="light" color="blue">
|
value={interval}
|
||||||
15% OFF
|
onChange={setInterval}
|
||||||
</Badge>
|
fullWidth
|
||||||
</Text>
|
data={[
|
||||||
</Flex>
|
{ label: "Monthly", value: "monthly" },
|
||||||
</Group>
|
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||||
</Group>
|
]}
|
||||||
</Stack>
|
/>
|
||||||
|
|
||||||
{/* Plans Grid */}
|
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||||
<Group justify="center" gap="lg" align="stretch">
|
{plan.name}
|
||||||
{plans.map((plan, index) => {
|
</Title>
|
||||||
const tieredPlan = plan;
|
<Text ta="center" size="lg" fw={700}>
|
||||||
const planSelectedTier =
|
{interval === "monthly" && (
|
||||||
tieredPlan.pricingTiers.find(
|
<>
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
${price}{" "}
|
||||||
) || tieredPlan.pricingTiers[0];
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
|
/user/month
|
||||||
const price = isAnnual
|
|
||||||
? planSelectedTier.yearly
|
|
||||||
: planSelectedTier.monthly;
|
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.name}
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
p="xl"
|
|
||||||
w={350}
|
|
||||||
miw={300}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* Plan Header */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Title order={3} size="h4">
|
|
||||||
{plan.name}
|
|
||||||
</Title>
|
|
||||||
{plan.description && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{plan.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Pricing */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group align="baseline" gap="xs">
|
|
||||||
<Title order={1} size="h1">
|
|
||||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
|
||||||
</Title>
|
|
||||||
<Text size="lg" c="dimmed">
|
|
||||||
per {isAnnual ? "month" : "month"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{isAnnual && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Billed annually
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
For {planSelectedTier.upTo} users
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</>
|
||||||
|
)}
|
||||||
|
{interval === "yearly" && (
|
||||||
|
<>
|
||||||
|
${yearlyMonthPrice}{" "}
|
||||||
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
|
/user/month
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<br/>
|
||||||
|
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||||
|
billed {interval}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* CTA Button */}
|
<Card.Section mt="lg">
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||||
Upgrade
|
Subscribe
|
||||||
</Button>
|
</Button>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
{/* Features */}
|
<Card.Section mt="md">
|
||||||
<List
|
<List
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={
|
center
|
||||||
<ThemeIcon size={20} radius="xl">
|
icon={
|
||||||
<IconCheck size={14} />
|
<ThemeIcon variant="light" size={24} radius="xl">
|
||||||
</ThemeIcon>
|
<IconCheck size={16} />
|
||||||
}
|
</ThemeIcon>
|
||||||
>
|
}
|
||||||
{plan.features.map((feature, featureIndex) => (
|
>
|
||||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
{plan.features.map((feature, index) => (
|
||||||
))}
|
<List.Item key={index}>{feature}</List.Item>
|
||||||
</List>
|
))}
|
||||||
</Stack>
|
</List>
|
||||||
</Card>
|
</Card.Section>
|
||||||
);
|
</Card>
|
||||||
})}
|
);
|
||||||
</Group>
|
})}
|
||||||
</Container>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,11 +25,6 @@ export interface IBilling {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
billingScheme: string | null;
|
|
||||||
tieredUpTo: string | null;
|
|
||||||
tieredFlatAmount: number | null;
|
|
||||||
tieredUnitAmount: number | null;
|
|
||||||
planName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICheckoutLink {
|
export interface ICheckoutLink {
|
||||||
@ -47,18 +42,9 @@ export interface IBillingPlan {
|
|||||||
monthlyId: string;
|
monthlyId: string;
|
||||||
yearlyId: string;
|
yearlyId: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
price?: {
|
price: {
|
||||||
monthly: string;
|
monthly: string;
|
||||||
yearly: string;
|
yearly: string;
|
||||||
};
|
};
|
||||||
features: string[];
|
features: string[];
|
||||||
billingScheme: string | null;
|
|
||||||
pricingTiers: PricingTier[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PricingTier {
|
|
||||||
upTo: number;
|
|
||||||
monthly?: number;
|
|
||||||
yearly?: number;
|
|
||||||
custom?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
export function PosthogUser() {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
const user = currentUser?.user;
|
|
||||||
const workspace = currentUser?.workspace;
|
|
||||||
if (!user || !workspace) return;
|
|
||||||
|
|
||||||
posthog?.identify(user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
workspaceId: user.workspaceId,
|
|
||||||
workspaceHostname: workspace.hostname,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
posthog?.group("workspace", workspace.id, {
|
|
||||||
name: workspace.name,
|
|
||||||
hostname: workspace.hostname,
|
|
||||||
plan: workspace?.plan,
|
|
||||||
status: workspace.status,
|
|
||||||
isOnTrial: !!workspace.trialEndAt,
|
|
||||||
hasStripeCustomerId: !!workspace.stripeCustomerId,
|
|
||||||
memberCount: workspace.memberCount,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: workspace.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [posthog, currentUser]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -18,7 +18,6 @@ import classes from "@/features/auth/components/auth.module.css";
|
|||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@ -72,43 +71,39 @@ export function InviteSignUpForm() {
|
|||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<SsoLogin />
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<TextInput
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
label={t("Name")}
|
||||||
|
placeholder={t("enter your full name")}
|
||||||
|
variant="filled"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
{!invitation.enforceSso && (
|
<TextInput
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
id="email"
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
type="email"
|
||||||
<TextInput
|
label={t("Email")}
|
||||||
id="name"
|
value={invitation.email}
|
||||||
type="text"
|
disabled
|
||||||
label={t("Name")}
|
variant="filled"
|
||||||
placeholder={t("enter your full name")}
|
mt="md"
|
||||||
variant="filled"
|
/>
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
<PasswordInput
|
||||||
id="email"
|
label={t("Password")}
|
||||||
type="email"
|
placeholder={t("Your password")}
|
||||||
label={t("Email")}
|
variant="filled"
|
||||||
value={invitation.email}
|
mt="md"
|
||||||
disabled
|
{...form.getInputProps("password")}
|
||||||
variant="filled"
|
/>
|
||||||
mt="md"
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
/>
|
{t("Sign Up")}
|
||||||
|
</Button>
|
||||||
<PasswordInput
|
</form>
|
||||||
label={t("Password")}
|
</Stack>
|
||||||
placeholder={t("Your password")}
|
|
||||||
variant="filled"
|
|
||||||
mt="md"
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
|
||||||
{t("Sign Up")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
|||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
workspaceName: z.string().trim().max(50).optional(),
|
workspaceName: z.string().trim().min(3).max(50),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@ -60,17 +60,15 @@ export function SetupWorkspaceForm() {
|
|||||||
{isCloud() && <SsoCloudSignup />}
|
{isCloud() && <SsoCloudSignup />}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
{!isCloud() && (
|
<TextInput
|
||||||
<TextInput
|
id="workspaceName"
|
||||||
id="workspaceName"
|
type="text"
|
||||||
type="text"
|
label={t("Workspace Name")}
|
||||||
label={t("Workspace Name")}
|
placeholder={t("e.g ACME Inc")}
|
||||||
placeholder={t("e.g ACME Inc")}
|
variant="filled"
|
||||||
variant="filled"
|
mt="md"
|
||||||
mt="md"
|
{...form.getInputProps("workspaceName")}
|
||||||
{...form.getInputProps("workspaceName")}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface IRegister {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ISetupWorkspace {
|
export interface ISetupWorkspace {
|
||||||
workspaceName?: string;
|
workspaceName: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -36,5 +36,5 @@ export interface IVerifyUserToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollabToken {
|
export interface ICollabToken {
|
||||||
token?: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,12 +12,6 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
-ms-word-break: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|||||||
@ -116,12 +116,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
onCreate: (instance) => {
|
|
||||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@ -183,8 +177,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
<LinkSelector
|
<LinkSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={(value) => {
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(value);
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
|
|||||||
@ -156,11 +156,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (name === "Default") {
|
editor.commands.unsetColor();
|
||||||
editor.commands.unsetColor();
|
name !== "Default" &&
|
||||||
} else {
|
editor
|
||||||
editor.chain().focus().setColor(color || "").run();
|
.chain()
|
||||||
}
|
.focus()
|
||||||
|
.setColor(color || "")
|
||||||
|
.run();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
|
|||||||
@ -15,13 +15,13 @@ import {
|
|||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import i18n from "i18next";
|
|
||||||
import {
|
import {
|
||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
} from "@docmost/editor-ext";
|
} from "@/features/editor/components/embed/providers.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@ -32,7 +32,7 @@ const schema = z.object({
|
|||||||
|
|
||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes } = props;
|
||||||
const { src, provider } = node.attrs;
|
const { src, provider } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
@ -50,16 +50,8 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const embedProvider = getEmbedProviderById(provider);
|
const embedProvider = getEmbedProviderById(provider);
|
||||||
if (embedProvider.id === "iframe") {
|
|
||||||
updateAttributes({ src: data.url });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (embedProvider.regex.test(data.url)) {
|
if (embedProvider.regex.test(data.url)) {
|
||||||
updateAttributes({ src: data.url });
|
updateAttributes({ src: data.url });
|
||||||
} else {
|
} else {
|
||||||
@ -89,13 +81,7 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||||
width={300}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
shadow="md"
|
|
||||||
disabled={!editor.isEditable}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
@ -115,7 +101,7 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
<Text component="span" size="lg" c="dimmed">
|
||||||
{t("Embed {{provider}}", {
|
{t("Embed {{provider}}", {
|
||||||
provider: getEmbedProviderById(provider)?.name,
|
provider: getEmbedProviderById(provider).name,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,117 +7,102 @@ export interface IEmbedProvider {
|
|||||||
|
|
||||||
export const embedProviders: IEmbedProvider[] = [
|
export const embedProviders: IEmbedProvider[] = [
|
||||||
{
|
{
|
||||||
id: "loom",
|
id: 'loom',
|
||||||
name: "Loom",
|
name: 'Loom',
|
||||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/embed/")) {
|
if(url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://loom.com/embed/${match[1]}`;
|
return `https://loom.com/embed/${match[1]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "airtable",
|
id: 'airtable',
|
||||||
name: "Airtable",
|
name: 'Airtable',
|
||||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
const path = url.split("airtable.com/");
|
const path = url.split('airtable.com/');
|
||||||
if (url.includes("/embed/")) {
|
if(url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://airtable.com/embed/${path[1]}`;
|
return `https://airtable.com/embed/${path[1]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "figma",
|
id: 'figma',
|
||||||
name: "Figma",
|
name: 'Figma',
|
||||||
regex:
|
regex: /^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
||||||
/^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "typeform",
|
'id': 'typeform',
|
||||||
name: "Typeform",
|
name: 'Typeform',
|
||||||
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "miro",
|
id: 'miro',
|
||||||
name: "Miro",
|
name: 'Miro',
|
||||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/live-embed/")) {
|
if(url.includes("/live-embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "youtube",
|
id: 'youtube',
|
||||||
name: "YouTube",
|
name: 'YouTube',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/embed/")) {
|
if (url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vimeo",
|
id: 'vimeo',
|
||||||
name: "Vimeo",
|
name: 'Vimeo',
|
||||||
regex:
|
regex: /^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
||||||
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match) => {
|
||||||
return `https://player.vimeo.com/video/${match[4]}`;
|
return `https://player.vimeo.com/video/${match[4]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "framer",
|
id: 'framer',
|
||||||
name: "Framer",
|
name: 'Framer',
|
||||||
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gdrive",
|
id: 'gdrive',
|
||||||
name: "Google Drive",
|
name: 'Google Drive',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match) => {
|
||||||
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gsheets",
|
id: 'gsheets',
|
||||||
name: "Google Sheets",
|
name: 'Google Sheets',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url
|
||||||
},
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "iframe",
|
|
||||||
name: "Iframe",
|
|
||||||
regex: /any-iframe/,
|
|
||||||
getEmbedUrl: (match, url) => {
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getEmbedProviderById(id: string) {
|
export function getEmbedProviderById(id: string) {
|
||||||
return embedProviders.find(
|
return embedProviders.find(provider => provider.id.toLowerCase() === id.toLowerCase());
|
||||||
(provider) => provider.id.toLowerCase() === id.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEmbedResult {
|
export interface IEmbedResult {
|
||||||
@ -131,12 +116,14 @@ export function getEmbedUrlAndProvider(url: string): IEmbedResult {
|
|||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
embedUrl: provider.getEmbedUrl(match, url),
|
embedUrl: provider.getEmbedUrl(match, url),
|
||||||
provider: provider.name.toLowerCase(),
|
provider: provider.name.toLowerCase()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
embedUrl: url,
|
embedUrl: url,
|
||||||
provider: "iframe",
|
provider: 'iframe',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
type LibraryItems = any;
|
|
||||||
|
|
||||||
type LibraryPersistedData = {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface LibraryPersistenceAdapter {
|
|
||||||
load(metadata: { source: "load" | "save" }):
|
|
||||||
| Promise<{ libraryItems: LibraryItems } | null>
|
|
||||||
| {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
|
|
||||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "excalidrawLibrary";
|
|
||||||
|
|
||||||
export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
|
||||||
async load() {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
||||||
if (data) {
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
async save(libraryData) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
"Error while saving library from Excalidraw to localStorage",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -13,8 +13,7 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
|||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
|
||||||
import { IAttachment } from "@/lib/types";
|
import { IAttachment } from "@/lib/types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@ -22,8 +21,6 @@ import { IconEdit } from "@tabler/icons-react";
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const Excalidraw = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
@ -38,10 +35,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
useHandleLibrary({
|
|
||||||
excalidrawAPI,
|
|
||||||
adapter: localStorageLibraryAdapter,
|
|
||||||
});
|
|
||||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const computedColorScheme = useComputedColorScheme();
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
type SearchAndReplaceAtomType = {
|
|
||||||
isOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
|
||||||
isOpen: false,
|
|
||||||
});
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
Flex,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconArrowNarrowDown,
|
|
||||||
IconArrowNarrowUp,
|
|
||||||
IconLetterCase,
|
|
||||||
IconReplace,
|
|
||||||
IconSearch,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import classes from "./search-replace.module.css";
|
|
||||||
|
|
||||||
interface PageFindDialogDialogProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
editable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [replaceText, setReplaceText] = useState("");
|
|
||||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
|
||||||
{ isReplaceShow: false, color: "gray" },
|
|
||||||
{ isReplaceShow: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
|
||||||
{ isCaseSensitive: false, color: "gray" },
|
|
||||||
{ isCaseSensitive: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setReplaceText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setSearchText("");
|
|
||||||
setReplaceText("");
|
|
||||||
setPageFindState({ isOpen: false });
|
|
||||||
// Reset replace button state when closing
|
|
||||||
if (replaceButton.isReplaceShow) {
|
|
||||||
replaceButtonToggle();
|
|
||||||
}
|
|
||||||
// Clear search term in editor
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setSearchTerm("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToSelection = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
|
||||||
const position: Range = results[resultIndex];
|
|
||||||
|
|
||||||
if (!position) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
editor.commands.setTextSelection(position);
|
|
||||||
|
|
||||||
const element = document.querySelector(".search-result-current");
|
|
||||||
if (element)
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
|
|
||||||
editor.commands.setTextSelection(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
editor.commands.nextSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const previous = () => {
|
|
||||||
editor.commands.previousSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replace = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replace();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceAll = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replaceAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setSearchTerm(searchText);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
editor.commands.selectCurrentItem();
|
|
||||||
}, [searchText]);
|
|
||||||
|
|
||||||
const handleOpenEvent = (e) => {
|
|
||||||
setPageFindState({ isOpen: true });
|
|
||||||
const selectedText = editor.state.doc.textBetween(
|
|
||||||
editor.state.selection.from,
|
|
||||||
editor.state.selection.to,
|
|
||||||
);
|
|
||||||
if (selectedText !== "") {
|
|
||||||
setSearchText(selectedText);
|
|
||||||
}
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEvent = (e) => {
|
|
||||||
closeDialog();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
!pageFindState.isOpen && closeDialog();
|
|
||||||
|
|
||||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.removeEventListener(
|
|
||||||
"closeFindDialogFromEditor",
|
|
||||||
handleCloseEvent,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [pageFindState.isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
goToSelection();
|
|
||||||
}, [caseSensitive]);
|
|
||||||
|
|
||||||
const resultsCount = useMemo(
|
|
||||||
() =>
|
|
||||||
searchText.trim() === ""
|
|
||||||
? ""
|
|
||||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
|
||||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
|
||||||
1 +
|
|
||||||
"/" +
|
|
||||||
editor?.storage?.searchAndReplace?.results.length
|
|
||||||
: t("Not found"),
|
|
||||||
[
|
|
||||||
searchText,
|
|
||||||
editor?.storage?.searchAndReplace?.resultIndex,
|
|
||||||
editor?.storage?.searchAndReplace?.results.length,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
closeDialog();
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
className={classes.findDialog}
|
|
||||||
opened={pageFindState.isOpen}
|
|
||||||
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
w={"auto"}
|
|
||||||
position={{ top: 90, right: 50 }}
|
|
||||||
withBorder
|
|
||||||
transitionProps={{ transition: "slide-down" }}
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={t("Find")}
|
|
||||||
leftSection={<IconSearch size={16} />}
|
|
||||||
rightSection={
|
|
||||||
<Text size="xs" ta="right">
|
|
||||||
{resultsCount}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
rightSectionWidth="70"
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={220}
|
|
||||||
onChange={searchInputEvent}
|
|
||||||
value={searchText}
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", next],
|
|
||||||
["shift+Enter", previous],
|
|
||||||
["alt+C", caseSensitiveToggle],
|
|
||||||
//@ts-ignore
|
|
||||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
|
||||||
<IconArrowNarrowUp
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Next match (Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
|
||||||
<IconArrowNarrowDown
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Match case (Alt+C)")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={caseSensitive.color}
|
|
||||||
onClick={() => caseSensitiveToggle()}
|
|
||||||
>
|
|
||||||
<IconLetterCase
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
{editable && (
|
|
||||||
<Tooltip label={t("Replace")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={replaceButton.color}
|
|
||||||
onClick={() => replaceButtonToggle()}
|
|
||||||
>
|
|
||||||
<IconReplace
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label={t("Close (Escape)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
|
||||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
{replaceButton.isReplaceShow && editable && (
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
placeholder={t("Replace")}
|
|
||||||
leftSection={<IconReplace size={16} />}
|
|
||||||
rightSection={<div></div>}
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={180}
|
|
||||||
autoFocus
|
|
||||||
onChange={replaceInputEvent}
|
|
||||||
value={replaceText}
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", replace],
|
|
||||||
["ctrl+alt+Enter", replaceAll],
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Replace (Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replace}
|
|
||||||
>
|
|
||||||
{t("Replace")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replaceAll}
|
|
||||||
>
|
|
||||||
{t("Replace all")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SearchAndReplaceDialog;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
.findDialog{
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.findDialog div[data-position="right"].mantine-Input-section {
|
|
||||||
justify-content: right;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
@ -17,8 +17,8 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconCalendar, IconAppWindow,
|
IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@ -357,20 +357,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Iframe embed",
|
|
||||||
description: "Embed any Iframe",
|
|
||||||
searchTerms: ["iframe"],
|
|
||||||
icon: IconAppWindow,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.setEmbed({ provider: "iframe" })
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Airtable",
|
title: "Airtable",
|
||||||
description: "Embed Airtable",
|
description: "Embed Airtable",
|
||||||
|
|||||||
@ -17,9 +17,9 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowInsertBottom,
|
IconRowInsertBottom,
|
||||||
IconRowInsertTop,
|
IconRowInsertTop,
|
||||||
IconRowRemove, IconTableColumn, IconTableRow,
|
IconRowRemove,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -50,14 +50,6 @@ export const TableMenu = React.memo(
|
|||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderColumn = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderColumn().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const toggleHeaderRow = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderRow().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const addColumnLeft = useCallback(() => {
|
const addColumnLeft = useCallback(() => {
|
||||||
editor.chain().focus().addColumnBefore().run();
|
editor.chain().focus().addColumnBefore().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
@ -188,30 +180,6 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderRow}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header row")}
|
|
||||||
>
|
|
||||||
<IconTableRow size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header column")}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderColumn}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header column")}
|
|
||||||
>
|
|
||||||
<IconTableColumn size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete table")}>
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
|
|||||||
@ -36,7 +36,6 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
|
||||||
Mention,
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@ -59,7 +58,6 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
|||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
import abap from "highlightjs-sap-abap";
|
|
||||||
import elixir from "highlight.js/lib/languages/elixir";
|
import elixir from "highlight.js/lib/languages/elixir";
|
||||||
import erlang from "highlight.js/lib/languages/erlang";
|
import erlang from "highlight.js/lib/languages/erlang";
|
||||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||||
@ -74,12 +72,11 @@ import i18n from "@/i18n.ts";
|
|||||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||||
import EmojiCommand from "./emoji-command";
|
import EmojiCommand from "./emoji-command";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import { countWords } from "alfaaz";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
lowlight.register("powershell", powershell);
|
lowlight.register("powershell", powershell);
|
||||||
lowlight.register("abap", abap);
|
lowlight.register("powershell", powershell);
|
||||||
lowlight.register("erlang", erlang);
|
lowlight.register("erlang", erlang);
|
||||||
lowlight.register("elixir", elixir);
|
lowlight.register("elixir", elixir);
|
||||||
lowlight.register("dockerfile", dockerfile);
|
lowlight.register("dockerfile", dockerfile);
|
||||||
@ -215,25 +212,7 @@ export const mainExtensions = [
|
|||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({
|
CharacterCount
|
||||||
wordCounter: (text) => countWords(text),
|
|
||||||
}),
|
|
||||||
SearchAndReplace.extend({
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
'Mod-f': () => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
'Escape': () => {
|
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).configure(),
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
@ -249,4 +228,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
|||||||
color: randomElement(userColors),
|
color: randomElement(userColors),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@ -42,11 +42,7 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||||
pageId={pageId}
|
|
||||||
editable={editable}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
@ -39,7 +45,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
|||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
@ -47,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
@ -67,11 +71,7 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const ydocRef = useRef<Y.Doc | null>(null);
|
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||||
if (!ydocRef.current) {
|
|
||||||
ydocRef.current = new Y.Doc();
|
|
||||||
}
|
|
||||||
const ydoc = ydocRef.current;
|
|
||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
@ -85,126 +85,67 @@ export default function PageEditor({
|
|||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
|
|
||||||
// Providers only created once per pageId
|
const localProvider = useMemo(() => {
|
||||||
const providersRef = useRef<{
|
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||||
local: IndexeddbPersistence;
|
|
||||||
remote: HocuspocusProvider;
|
|
||||||
} | null>(null);
|
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
|
||||||
|
|
||||||
const localProvider = providersRef.current?.local;
|
provider.on("synced", () => {
|
||||||
const remoteProvider = providersRef.current?.remote;
|
setLocalSynced(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Track when collaborative provider is ready and synced
|
return provider;
|
||||||
const [collabReady, setCollabReady] = useState(false);
|
}, [pageId, ydoc]);
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
|
||||||
isLocalSynced &&
|
|
||||||
isRemoteSynced
|
|
||||||
) {
|
|
||||||
setCollabReady(true);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const remoteProvider = useMemo(() => {
|
||||||
if (!providersRef.current) {
|
const provider = new HocuspocusProvider({
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
name: documentName,
|
||||||
local.on("synced", () => setLocalSynced(true));
|
url: collaborationURL,
|
||||||
const remote = new HocuspocusProvider({
|
document: ydoc,
|
||||||
name: documentName,
|
token: collabQuery?.token,
|
||||||
url: collaborationURL,
|
connect: false,
|
||||||
document: ydoc,
|
preserveConnection: false,
|
||||||
token: collabQuery?.token,
|
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||||
connect: true,
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
preserveConnection: false,
|
const now = Date.now().valueOf() / 1000;
|
||||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
const isTokenExpired = now >= payload.exp;
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
if (isTokenExpired) {
|
||||||
const now = Date.now().valueOf() / 1000;
|
refetchCollabToken();
|
||||||
const isTokenExpired = now >= payload.exp;
|
}
|
||||||
if (isTokenExpired) {
|
},
|
||||||
refetchCollabToken().then((result) => {
|
onStatus: (status) => {
|
||||||
if (result.data?.token) {
|
if (status.status === "connected") {
|
||||||
remote.disconnect();
|
setYjsConnectionStatus(status.status);
|
||||||
setTimeout(() => {
|
}
|
||||||
remote.configuration.token = result.data.token;
|
},
|
||||||
remote.connect();
|
});
|
||||||
}, 100);
|
|
||||||
}
|
provider.on("synced", () => {
|
||||||
});
|
setRemoteSynced(true);
|
||||||
}
|
});
|
||||||
},
|
|
||||||
onStatus: (status) => {
|
provider.on("disconnect", () => {
|
||||||
if (status.status === "connected") {
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
setYjsConnectionStatus(status.status);
|
});
|
||||||
}
|
|
||||||
},
|
return provider;
|
||||||
});
|
}, [ydoc, pageId, collabQuery?.token]);
|
||||||
remote.on("synced", () => setRemoteSynced(true));
|
|
||||||
remote.on("disconnect", () => {
|
useLayoutEffect(() => {
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
remoteProvider.connect();
|
||||||
});
|
|
||||||
providersRef.current = { local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
providersRef.current?.remote.destroy();
|
setRemoteSynced(false);
|
||||||
providersRef.current?.local.destroy();
|
setLocalSynced(false);
|
||||||
providersRef.current = null;
|
remoteProvider.destroy();
|
||||||
|
localProvider.destroy();
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [remoteProvider, localProvider]);
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
|
||||||
// Handle token updates by reconnecting with new token
|
|
||||||
if (providersRef.current?.remote && collabQuery?.token) {
|
|
||||||
const currentToken = providersRef.current.remote.configuration.token;
|
|
||||||
if (currentToken !== collabQuery.token) {
|
|
||||||
// Token has changed, need to reconnect with new token
|
|
||||||
providersRef.current.remote.disconnect();
|
|
||||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
|
||||||
providersRef.current.remote.connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providersReady || !providersRef.current) return;
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
if (
|
|
||||||
isIdle &&
|
|
||||||
documentState === "hidden" &&
|
|
||||||
remoteProvider.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
remoteProvider.disconnect();
|
|
||||||
setIsCollabReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
documentState === "visible" &&
|
|
||||||
remoteProvider.status === WebSocketStatus.Disconnected
|
|
||||||
) {
|
|
||||||
resetIdle();
|
|
||||||
remoteProvider.connect();
|
|
||||||
setTimeout(() => setIsCollabReady(true), 500);
|
|
||||||
}
|
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remoteProvider, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [remoteProvider, currentUser?.user]);
|
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@ -258,7 +199,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider],
|
[pageId, editable, remoteProvider?.status],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
@ -311,6 +252,29 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [remoteProvider?.status]);
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
documentState === "hidden" &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
remoteProvider.disconnect();
|
||||||
|
setIsCollabReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
documentState === "visible" &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
resetIdle();
|
||||||
|
remoteProvider.connect();
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCollabReady(true);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}, [isIdle, documentState, remoteProvider]);
|
||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -326,54 +290,11 @@ export default function PageEditor({
|
|||||||
return () => clearTimeout(collabReadyTimeout);
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
return isCollabReady ? (
|
||||||
// Only honor user default page edit mode preference and permissions
|
<div>
|
||||||
if (editor) {
|
|
||||||
if (userPageEditMode && editable) {
|
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
|
||||||
editor.setEditable(true);
|
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, editor, editable]);
|
|
||||||
|
|
||||||
const hasConnectedOnceRef = useRef(false);
|
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!hasConnectedOnceRef.current &&
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
hasConnectedOnceRef.current = true;
|
|
||||||
setShowStatic(false);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status]);
|
|
||||||
|
|
||||||
if (showStatic) {
|
|
||||||
return (
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={mainExtensions}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
{editor && (
|
|
||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editor && editor.isEditable && (
|
{editor && editor.isEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
@ -387,12 +308,21 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
style={{ paddingBottom: "20vh" }}
|
style={{ paddingBottom: "20vh" }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<EditorProvider
|
||||||
|
editable={false}
|
||||||
|
immediatelyRender={true}
|
||||||
|
extensions={mainExtensions}
|
||||||
|
content={content}
|
||||||
|
></EditorProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,12 +71,4 @@
|
|||||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||||
transform: rotateZ(90deg);
|
transform: rotateZ(90deg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
|
|
||||||
transform: rotateZ(90deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
.search-result{
|
|
||||||
background: #ffff65;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-current{
|
|
||||||
background: #ffc266 !important;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
@ -9,5 +9,5 @@
|
|||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
@import "./print.css";
|
@import "./print.css";
|
||||||
@import "./find.css";
|
|
||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,8 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||||
updatePageData,
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
useUpdateTitlePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
import { History } from "@tiptap/extension-history";
|
import { History } from "@tiptap/extension-history";
|
||||||
@ -24,8 +21,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
||||||
import { UpdateEvent } from "@/features/websocket/types";
|
import { UpdateEvent } from "@/features/websocket/types";
|
||||||
import localEmitter from "@/lib/local-emitter.ts";
|
import localEmitter from "@/lib/local-emitter.ts";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -43,16 +38,12 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||||
useUpdateTitlePageMutation();
|
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
const [activePageId, setActivePageId] = useState(pageId);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
const userPageEditMode =
|
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -112,12 +103,7 @@ export function TitleEditor({
|
|||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: {
|
payload: { title: page.title, slugId: page.slugId },
|
||||||
title: page.title,
|
|
||||||
slugId: page.slugId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
icon: page.icon,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
if (page.title !== titleEditor.getText()) return;
|
||||||
@ -150,30 +136,9 @@ export function TitleEditor({
|
|||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTitleKeyDown(event) {
|
||||||
// honor user default page edit mode preference
|
|
||||||
if (userPageEditMode && titleEditor && editable) {
|
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
|
||||||
titleEditor.setEditable(true);
|
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
|
||||||
titleEditor.setEditable(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, titleEditor, editable]);
|
|
||||||
|
|
||||||
const openSearchDialog = () => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleTitleKeyDown(event: any) {
|
|
||||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||||
|
|
||||||
// Prevent focus shift when IME composition is active
|
|
||||||
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
|
||||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
const { $head } = titleEditor.state.selection;
|
const { $head } = titleEditor.state.selection;
|
||||||
|
|
||||||
@ -187,16 +152,5 @@ export function TitleEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||||
<EditorContent
|
|
||||||
editor={titleEditor}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
// First handle the search hotkey
|
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
|
||||||
|
|
||||||
// Then handle other key events
|
|
||||||
handleTitleKeyDown(event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
|
||||||
|
|
||||||
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
|
||||||
const req = await api.post<IFileTask>("/file-tasks/info", {
|
|
||||||
fileTaskId: fileTaskId,
|
|
||||||
});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFileTasks(): Promise<IFileTask[]> {
|
|
||||||
const req = await api.post<IFileTask[]>("/file-tasks");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
export interface IFileTask {
|
|
||||||
id: string;
|
|
||||||
type: "import" | "export";
|
|
||||||
source: string;
|
|
||||||
status: string;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
fileSize: number;
|
|
||||||
fileExt: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
creatorId: string;
|
|
||||||
spaceId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
deletedAt: string | null;
|
|
||||||
}
|
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export async function getPageHistoryList(
|
export async function getPageHistoryList(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<IPagination<IPageHistory>> {
|
): Promise<IPageHistory[]> {
|
||||||
const req = await api.post("/pages/history", {
|
const req = await api.post("/pages/history", {
|
||||||
pageId,
|
pageId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,30 +1,24 @@
|
|||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--mantine-color-default-color);
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mantine-Breadcrumbs-breadcrumb {
|
|
||||||
min-width: 1px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--mantine-color-default-color);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-Breadcrumbs-breadcrumb {
|
||||||
|
min-width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.truncatedText {
|
.truncatedText {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumbDiv {
|
|
||||||
overflow: hidden;
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -161,7 +161,7 @@ export default function Breadcrumb() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.breadcrumbDiv}>
|
<div style={{ overflow: "hidden" }}>
|
||||||
{breadcrumbNodes && (
|
{breadcrumbNodes && (
|
||||||
<Breadcrumbs className={classes.breadcrumbs}>
|
<Breadcrumbs className={classes.breadcrumbs}>
|
||||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconSearch,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@ -17,12 +16,7 @@ import React from "react";
|
|||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import {
|
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||||
getHotkeyHandler,
|
|
||||||
useClipboard,
|
|
||||||
useDisclosure,
|
|
||||||
useHotkeys,
|
|
||||||
} from "@mantine/hooks";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@ -38,9 +32,7 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||||
@ -53,26 +45,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||||
|
|
||||||
useHotkeys(
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"mod+F",
|
|
||||||
() => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Escape",
|
|
||||||
() => {
|
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{yjsConnectionStatus === "disconnected" && (
|
{yjsConnectionStatus === "disconnected" && (
|
||||||
@ -87,8 +59,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
|
||||||
|
|
||||||
<ShareModal readOnly={readOnly} />
|
<ShareModal readOnly={readOnly} />
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
|
|||||||
@ -1,27 +1,15 @@
|
|||||||
.header {
|
.header {
|
||||||
height: 45px;
|
height: 45px;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
padding-left: var(--mantine-spacing-md);
|
padding-left: var(--mantine-spacing-md);
|
||||||
padding-right: var(--mantine-spacing-md);
|
padding-right: var(--mantine-spacing-md);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
top: var(--app-shell-header-offset, 0rem);
|
top: var(--app-shell-header-offset, 0rem);
|
||||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
@media print {
|
||||||
padding-left: var(--mantine-spacing-xs);
|
display: none;
|
||||||
padding-right: var(--mantine-spacing-xs);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
gap: var(--mantine-spacing-sm);
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,10 @@ interface Props {
|
|||||||
export default function PageHeader({ readOnly }: Props) {
|
export default function PageHeader({ readOnly }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
|
|
||||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||||
<PageHeaderMenu readOnly={readOnly} />
|
<PageHeaderMenu readOnly={readOnly} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -1,38 +1,18 @@
|
|||||||
|
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
SimpleGrid,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrandNotion,
|
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileCode,
|
IconFileCode,
|
||||||
IconFileTypeZip,
|
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import { importPage } from "@/features/page/services/page-service.ts";
|
||||||
importPage,
|
|
||||||
importZip,
|
|
||||||
} from "@/features/page/services/page-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
|
||||||
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
|
||||||
import { formatBytes } from "@/lib";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
|
|
||||||
import { queryClient } from "@/main.tsx";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|
||||||
|
|
||||||
interface PageImportModalProps {
|
interface PageImportModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -56,7 +36,6 @@ export default function PageImportModal({
|
|||||||
yOffset="10vh"
|
yOffset="10vh"
|
||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
keepMounted={true}
|
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
@ -80,133 +59,6 @@ interface ImportFormatSelection {
|
|||||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
|
||||||
|
|
||||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
|
||||||
if (!selectedFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
onClose();
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
id: "import",
|
|
||||||
title: t("Uploading import file"),
|
|
||||||
message: t("Please don't close this tab."),
|
|
||||||
loading: true,
|
|
||||||
withCloseButton: false,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const importTask = await importZip(selectedFile, spaceId, source);
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
title: t("Importing pages"),
|
|
||||||
message: t(
|
|
||||||
"Page import is in progress. You can check back later if this takes longer.",
|
|
||||||
),
|
|
||||||
loading: true,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setFileTaskId(importTask.id);
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Failed to upload import file", err);
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "red",
|
|
||||||
title: t("Failed to upload import file"),
|
|
||||||
message: err?.response.data.message,
|
|
||||||
icon: <IconX size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fileTaskId) return;
|
|
||||||
|
|
||||||
const intervalId = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const fileTask = await getFileTaskById(fileTaskId);
|
|
||||||
const status = fileTask.status;
|
|
||||||
|
|
||||||
if (status === "success") {
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "teal",
|
|
||||||
title: t("Import complete"),
|
|
||||||
message: t("Your pages were successfully imported."),
|
|
||||||
icon: <IconCheck size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setFileTaskId(null);
|
|
||||||
|
|
||||||
await queryClient.refetchQueries({
|
|
||||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "refetchRootTreeNodeEvent",
|
|
||||||
spaceId: spaceId,
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "failed") {
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "red",
|
|
||||||
title: t("Page import failed"),
|
|
||||||
message: t(
|
|
||||||
"Something went wrong while importing pages: {{reason}}.",
|
|
||||||
{
|
|
||||||
reason: fileTask.errorMessage,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
icon: <IconX size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setFileTaskId(null);
|
|
||||||
console.error(fileTask.errorMessage);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
notifications.update({
|
|
||||||
id: "import",
|
|
||||||
color: "red",
|
|
||||||
title: t("Import failed"),
|
|
||||||
message: t(
|
|
||||||
"Something went wrong while importing pages: {{reason}}.",
|
|
||||||
{
|
|
||||||
reason: err.response?.data.message,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
icon: <IconX size={18} />,
|
|
||||||
loading: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setFileTaskId(null);
|
|
||||||
console.error("Failed to fetch import status", err);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}, [fileTaskId]);
|
|
||||||
|
|
||||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
if (!selectedFiles) {
|
if (!selectedFiles) {
|
||||||
@ -268,7 +120,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
@ -297,76 +148,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
<FileButton
|
|
||||||
onChange={(file) => handleZipUpload(file, "notion")}
|
|
||||||
accept="application/zip"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button
|
|
||||||
justify="start"
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconBrandNotion size={18} />}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
Notion
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
<FileButton
|
|
||||||
onChange={(file) => handleZipUpload(file, "confluence")}
|
|
||||||
accept="application/zip"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip
|
|
||||||
label="Available in enterprise edition"
|
|
||||||
disabled={canUseConfluence}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
disabled={!canUseConfluence}
|
|
||||||
justify="start"
|
|
||||||
variant="default"
|
|
||||||
leftSection={<ConfluenceIcon size={18} />}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
Confluence
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Group justify="center" gap="xl" mih={150}>
|
|
||||||
<div>
|
|
||||||
<Text ta="center" size="lg" inline>
|
|
||||||
Import zip file
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" size="sm" c="dimmed" inline py="sm">
|
|
||||||
{t(
|
|
||||||
`Upload zip file containing Markdown and HTML files. Max: {{sizeLimit}}`,
|
|
||||||
{
|
|
||||||
sizeLimit: formatBytes(getFileImportSizeLimit()),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<FileButton
|
|
||||||
onChange={(file) => handleZipUpload(file, "generic")}
|
|
||||||
accept="application/zip"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Group justify="center">
|
|
||||||
<Button
|
|
||||||
justify="center"
|
|
||||||
leftSection={<IconFileTypeZip size={18} />}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{t("Upload file")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
InfiniteData,
|
|
||||||
QueryKey,
|
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
UseInfiniteQueryResult,
|
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
@ -17,7 +14,6 @@ import {
|
|||||||
movePage,
|
movePage,
|
||||||
getPageBreadcrumbs,
|
getPageBreadcrumbs,
|
||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
getAllSidebarPages,
|
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@ -60,9 +56,7 @@ export function useCreatePageMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {},
|
||||||
invalidateOnCreatePage(data);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to create page"), color: "red" });
|
notifications.show({ message: t("Failed to create page"), color: "red" });
|
||||||
},
|
},
|
||||||
@ -86,8 +80,6 @@ export function updatePageData(data: IPage) {
|
|||||||
if (pageById) {
|
if (pageById) {
|
||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateTitlePageMutation() {
|
export function useUpdateTitlePageMutation() {
|
||||||
@ -101,8 +93,6 @@ export function useUpdatePageMutation() {
|
|||||||
mutationFn: (data) => updatePage(data),
|
mutationFn: (data) => updatePage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
updatePage(data);
|
updatePage(data);
|
||||||
|
|
||||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -111,9 +101,8 @@ export function useDeletePageMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId),
|
mutationFn: (pageId: string) => deletePage(pageId),
|
||||||
onSuccess: (data, pageId) => {
|
onSuccess: () => {
|
||||||
notifications.show({ message: t("Page deleted successfully") });
|
notifications.show({ message: t("Page deleted successfully") });
|
||||||
invalidateOnDeletePage(pageId);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||||
@ -124,21 +113,15 @@ export function useDeletePageMutation() {
|
|||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
onSuccess: () => {
|
|
||||||
invalidateOnMovePage();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
export function useGetSidebarPagesQuery(
|
||||||
return useInfiniteQuery({
|
data: SidebarPagesParams,
|
||||||
|
): UseQueryResult<IPagination<IPage>, Error> {
|
||||||
|
return useQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: () => getSidebarPages(data),
|
||||||
initialPageParam: 1,
|
|
||||||
getPreviousPageParam: (firstPage) =>
|
|
||||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,16 +149,14 @@ export function usePageBreadcrumbsQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
export async function fetchAncestorChildren(params: SidebarPagesParams) {
|
||||||
// not using a hook here, so we can call it inside a useEffect hook
|
// not using a hook here, so we can call it inside a useEffect hook
|
||||||
const response = await queryClient.fetchQuery({
|
const response = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
queryKey: ["sidebar-pages", params],
|
||||||
queryFn: () => getAllSidebarPages(params),
|
queryFn: () => getSidebarPages(params),
|
||||||
staleTime: 30 * 60 * 1000,
|
staleTime: 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
return buildTree(response.items);
|
||||||
const allItems = response.pages.flatMap((page) => page.items);
|
|
||||||
return buildTree(allItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRecentChangesQuery(
|
export function useRecentChangesQuery(
|
||||||
@ -187,157 +168,3 @@ export function useRecentChangesQuery(
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|
||||||
const newPage: Partial<IPage> = {
|
|
||||||
creatorId: data.creatorId,
|
|
||||||
hasChildren: data.hasChildren,
|
|
||||||
icon: data.icon,
|
|
||||||
id: data.id,
|
|
||||||
parentPageId: data.parentPageId,
|
|
||||||
position: data.position,
|
|
||||||
slugId: data.slugId,
|
|
||||||
spaceId: data.spaceId,
|
|
||||||
title: data.title,
|
|
||||||
};
|
|
||||||
|
|
||||||
let queryKey: QueryKey = null;
|
|
||||||
if (data.parentPageId===null) {
|
|
||||||
queryKey = ['root-sidebar-pages', data.spaceId];
|
|
||||||
}else{
|
|
||||||
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
|
||||||
}
|
|
||||||
|
|
||||||
//update all sidebar pages
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page,index) => {
|
|
||||||
if (index === old.pages.length - 1) {
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
items: [...page.items, newPage],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
//update sidebar haschildren
|
|
||||||
if (data.parentPageId!==null){
|
|
||||||
//update sub sidebar pages haschildern
|
|
||||||
const subSideBarMatches = queryClient.getQueriesData({
|
|
||||||
queryKey: ['sidebar-pages'],
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
subSideBarMatches.forEach(([key, d]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
|
||||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//update root sidebar pages haschildern
|
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
|
||||||
queryKey: ['root-sidebar-pages', data.spaceId],
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
rootSideBarMatches.forEach(([key, d]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
|
||||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//update recent changes
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", data.spaceId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
|
||||||
let queryKey: QueryKey = null;
|
|
||||||
if(parentPageId===null){
|
|
||||||
queryKey = ['root-sidebar-pages', spaceId];
|
|
||||||
}else{
|
|
||||||
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
|
||||||
}
|
|
||||||
//update all sidebar pages
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((sidebarPage: IPage) =>
|
|
||||||
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
//update recent changes
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", spaceId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnMovePage() {
|
|
||||||
//for move invalidate all sidebars for now (how to do???)
|
|
||||||
//invalidate all root sidebar pages
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["root-sidebar-pages"],
|
|
||||||
});
|
|
||||||
//invalidate all sub sidebar pages
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ['sidebar-pages'],
|
|
||||||
});
|
|
||||||
// ---
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
|
||||||
//update all sidebar pages
|
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
|
||||||
});
|
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key, d]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//update recent changes
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -7,11 +7,9 @@ import {
|
|||||||
IPage,
|
IPage,
|
||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from '@/features/page/types/page.types';
|
} from "@/features/page/types/page.types";
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { InfiniteData } from "@tanstack/react-query";
|
|
||||||
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
@ -54,32 +52,6 @@ export async function getSidebarPages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllSidebarPages(
|
|
||||||
params: SidebarPagesParams,
|
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
|
||||||
let page = 1;
|
|
||||||
let hasNextPage = false;
|
|
||||||
const pages: IPagination<IPage>[] = [];
|
|
||||||
const pageParams: number[] = [];
|
|
||||||
|
|
||||||
do {
|
|
||||||
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
|
||||||
|
|
||||||
const data: IPagination<IPage> = req.data;
|
|
||||||
pages.push(data);
|
|
||||||
pageParams.push(page);
|
|
||||||
|
|
||||||
hasNextPage = data.meta.hasNextPage;
|
|
||||||
|
|
||||||
page += 1;
|
|
||||||
} while (hasNextPage);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageParams,
|
|
||||||
pages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPageBreadcrumbs(
|
export async function getPageBreadcrumbs(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<Partial<IPage[]>> {
|
): Promise<Partial<IPage[]>> {
|
||||||
@ -120,25 +92,6 @@ export async function importPage(file: File, spaceId: string) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importZip(
|
|
||||||
file: File,
|
|
||||||
spaceId: string,
|
|
||||||
source?: string,
|
|
||||||
): Promise<IFileTask> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("spaceId", spaceId);
|
|
||||||
formData.append("source", source);
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const req = await api.post<any>("/pages/import-zip", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
file: File,
|
file: File,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
|||||||
@ -1,19 +1,4 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { appendNodeChildren } from "../utils";
|
|
||||||
|
|
||||||
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||||
|
|
||||||
// Atom
|
|
||||||
export const appendNodeChildrenAtom = atom(
|
|
||||||
null,
|
|
||||||
(
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
{ parentId, children }: { parentId: string; children: SpaceTreeNode[] }
|
|
||||||
) => {
|
|
||||||
const currentTree = get(treeDataAtom);
|
|
||||||
const updatedTree = appendNodeChildren(currentTree, parentId, children);
|
|
||||||
set(treeDataAtom, updatedTree);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
fetchAllAncestorChildren,
|
fetchAncestorChildren,
|
||||||
useGetRootSidebarPagesQuery,
|
useGetRootSidebarPagesQuery,
|
||||||
usePageQuery,
|
usePageQuery,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
@ -24,10 +24,7 @@ import {
|
|||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
appendNodeChildrenAtom,
|
|
||||||
treeDataAtom,
|
|
||||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
@ -35,7 +32,6 @@ import {
|
|||||||
appendNodeChildren,
|
appendNodeChildren,
|
||||||
buildTree,
|
buildTree,
|
||||||
buildTreeWithChildren,
|
buildTreeWithChildren,
|
||||||
mergeRootTrees,
|
|
||||||
updateTreeNodeIcon,
|
updateTreeNodeIcon,
|
||||||
} from "@/features/page/tree/utils/utils.ts";
|
} from "@/features/page/tree/utils/utils.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@ -108,17 +104,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||||
const treeData = buildTree(allItems);
|
const treeData = buildTree(allItems);
|
||||||
|
|
||||||
setData((prev) => {
|
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
|
||||||
// fresh space; full reset
|
//Thoughts
|
||||||
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
|
// don't reset if there is data in state
|
||||||
setIsDataLoaded(true);
|
// we only expect to call this once on initial load
|
||||||
setOpenTreeNodes({});
|
// even if we decide to refetch, it should only update
|
||||||
return treeData;
|
// and append root pages instead of resetting the entire tree
|
||||||
}
|
// which looses async loaded children too
|
||||||
|
setData(treeData);
|
||||||
// same space; append only missing roots
|
setIsDataLoaded(true);
|
||||||
return mergeRootTrees(prev, treeData);
|
setOpenTreeNodes({});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}, [pagesData, hasNextPage]);
|
}, [pagesData, hasNextPage]);
|
||||||
|
|
||||||
@ -144,7 +140,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
if (ancestor.id === currentPage.id) {
|
if (ancestor.id === currentPage.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const children = await fetchAllAncestorChildren({
|
const children = await fetchAncestorChildren({
|
||||||
pageId: ancestor.id,
|
pageId: ancestor.id,
|
||||||
spaceId: ancestor.spaceId,
|
spaceId: ancestor.spaceId,
|
||||||
});
|
});
|
||||||
@ -241,7 +237,6 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
|
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
@ -267,10 +262,9 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
|
|
||||||
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||||
if (!node.data.hasChildren) return;
|
if (!node.data.hasChildren) return;
|
||||||
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
|
if (node.data.children && node.data.children.length > 0) {
|
||||||
// if (node.data.children && node.data.children.length > 0) {
|
return;
|
||||||
// return;
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: SidebarPagesParams = {
|
const params: SidebarPagesParams = {
|
||||||
@ -278,12 +272,21 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
spaceId: node.data.spaceId,
|
spaceId: node.data.spaceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const childrenTree = await fetchAllAncestorChildren(params);
|
const newChildren = await queryClient.fetchQuery({
|
||||||
|
queryKey: ["sidebar-pages", params],
|
||||||
appendChildren({
|
queryFn: () => getSidebarPages(params),
|
||||||
parentId: node.data.id,
|
staleTime: 10 * 60 * 1000,
|
||||||
children: childrenTree,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const childrenTree = buildTree(newChildren.items);
|
||||||
|
|
||||||
|
const updatedTreeData = appendNodeChildren(
|
||||||
|
treeData,
|
||||||
|
node.data.id,
|
||||||
|
childrenTree,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTreeData(updatedTreeData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch children:", error);
|
console.error("Failed to fetch children:", error);
|
||||||
}
|
}
|
||||||
@ -301,19 +304,17 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
|
|
||||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
handleUpdateNodeIcon(node.id, emoji.native);
|
handleUpdateNodeIcon(node.id, emoji.native);
|
||||||
updatePageMutation
|
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
||||||
.mutateAsync({ pageId: node.id, icon: emoji.native })
|
|
||||||
.then((data) => {
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
emit({
|
||||||
emit({
|
operation: "updateOne",
|
||||||
operation: "updateOne",
|
spaceId: node.data.spaceId,
|
||||||
spaceId: node.data.spaceId,
|
entity: ["pages"],
|
||||||
entity: ["pages"],
|
id: node.id,
|
||||||
id: node.id,
|
payload: { icon: emoji.native },
|
||||||
payload: { icon: emoji.native, parentPageId: data.parentPageId },
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
});
|
});
|
||||||
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveEmoji = () => {
|
const handleRemoveEmoji = () => {
|
||||||
@ -575,12 +576,6 @@ interface PageArrowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||||
useEffect(() => {
|
|
||||||
if (node.isOpen) {
|
|
||||||
onExpandTree();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMove: MoveHandler<T> = async (args: {
|
const onMove: MoveHandler<T> = (args: {
|
||||||
dragIds: string[];
|
dragIds: string[];
|
||||||
dragNodes: NodeApi<T>[];
|
dragNodes: NodeApi<T>[];
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@ -176,7 +176,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await movePageMutation.mutateAsync(payload);
|
movePageMutation.mutateAsync(payload);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
@ -206,23 +206,6 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPageInNode = (
|
|
||||||
node: { data: SpaceTreeNode; children?: any[] },
|
|
||||||
pageSlug: string
|
|
||||||
): boolean => {
|
|
||||||
if (node.data.slugId === pageSlug) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const item of node.children) {
|
|
||||||
if (item.data.slugId === pageSlug) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return isPageInNode(item, pageSlug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||||
try {
|
try {
|
||||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||||
@ -235,7 +218,8 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
tree.drop({ id: args.ids[0] });
|
tree.drop({ id: args.ids[0] });
|
||||||
setData(tree.data);
|
setData(tree.data);
|
||||||
|
|
||||||
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
|
// navigate only if the current url is same as the deleted page
|
||||||
|
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||||
navigate(getSpaceUrl(spaceSlug));
|
navigate(getSpaceUrl(spaceSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export const deleteTreeNode = (
|
|||||||
.filter((node) => node !== null);
|
.filter((node) => node !== null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||||
const nodeMap = {};
|
const nodeMap = {};
|
||||||
let result: SpaceTreeNode[] = [];
|
let result: SpaceTreeNode[] = [];
|
||||||
@ -163,55 +164,16 @@ export function appendNodeChildren(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
children: SpaceTreeNode[],
|
children: SpaceTreeNode[],
|
||||||
) {
|
) {
|
||||||
// Preserve deeper children if they exist and remove node if deleted
|
return treeItems.map((nodeItem) => {
|
||||||
return treeItems.map((node) => {
|
if (nodeItem.id === nodeId) {
|
||||||
if (node.id === nodeId) {
|
return { ...nodeItem, children };
|
||||||
const newIds = new Set(children.map((c) => c.id));
|
}
|
||||||
|
if (nodeItem.children) {
|
||||||
const existingMap = new Map(
|
|
||||||
(node.children ?? [])
|
|
||||||
.filter((c) => newIds.has(c.id))
|
|
||||||
.map((c) => [c.id, c]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const merged = children.map((newChild) => {
|
|
||||||
const existing = existingMap.get(newChild.id);
|
|
||||||
return existing && existing.children
|
|
||||||
? { ...newChild, children: existing.children }
|
|
||||||
: newChild;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...nodeItem,
|
||||||
children: merged,
|
children: appendNodeChildren(nodeItem.children, nodeId, children),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return nodeItem;
|
||||||
if (node.children) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
children: appendNodeChildren(node.children, nodeId, children),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge root nodes; keep existing ones intact, append new ones,
|
|
||||||
*/
|
|
||||||
export function mergeRootTrees(
|
|
||||||
prevRoots: SpaceTreeNode[],
|
|
||||||
incomingRoots: SpaceTreeNode[],
|
|
||||||
): SpaceTreeNode[] {
|
|
||||||
const seen = new Set(prevRoots.map((r) => r.id));
|
|
||||||
|
|
||||||
// add new roots that were not present before
|
|
||||||
const merged = [...prevRoots];
|
|
||||||
incomingRoots.forEach((node) => {
|
|
||||||
if (!seen.has(node.id)) merged.push(node);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortPositionKeys(merged);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -65,7 +65,6 @@ export interface IPageInput {
|
|||||||
icon: string;
|
icon: string;
|
||||||
coverPhoto: string;
|
coverPhoto: string;
|
||||||
position: string;
|
position: string;
|
||||||
isLocked: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExportPageParams {
|
export interface IExportPageParams {
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
import { Affix, Button } from "@mantine/core";
|
|
||||||
|
|
||||||
export default function ShareBranding() {
|
|
||||||
return (
|
|
||||||
<Affix position={{ bottom: 20, right: 20 }}>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
component="a"
|
|
||||||
target="_blank"
|
|
||||||
href="https://docmost.com?ref=public-share"
|
|
||||||
>
|
|
||||||
Powered by Docmost
|
|
||||||
</Button>
|
|
||||||
</Affix>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -36,7 +36,6 @@ import {
|
|||||||
} from "@/features/search/components/search-control.tsx";
|
} from "@/features/search/components/search-control.tsx";
|
||||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
||||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
|
||||||
|
|
||||||
const MemoizedSharedTree = React.memo(SharedTree);
|
const MemoizedSharedTree = React.memo(SharedTree);
|
||||||
|
|
||||||
@ -164,7 +163,16 @@ export default function ShareShell({
|
|||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
|
<Affix position={{ bottom: 20, right: 20 }}>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
component="a"
|
||||||
|
target="_blank"
|
||||||
|
href="https://docmost.com?ref=public-share"
|
||||||
|
>
|
||||||
|
Powered by Docmost
|
||||||
|
</Button>
|
||||||
|
</Affix>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
<AppShell.Aside
|
<AppShell.Aside
|
||||||
|
|||||||
@ -41,7 +41,6 @@ export interface ISharedPage extends IShare {
|
|||||||
level: number;
|
level: number;
|
||||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||||
};
|
};
|
||||||
hasLicenseKey: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IShareForPage extends IShare {
|
export interface IShareForPage extends IShare {
|
||||||
@ -71,5 +70,4 @@ export interface IShareInfoInput {
|
|||||||
export interface ISharedPageTree {
|
export interface ISharedPageTree {
|
||||||
share: IShare;
|
share: IShare;
|
||||||
pageTree: Partial<IPage[]>;
|
pageTree: Partial<IPage[]>;
|
||||||
hasLicenseKey: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,15 +42,14 @@ function LanguageSwitcher() {
|
|||||||
label={t("Select language")}
|
label={t("Select language")}
|
||||||
data={[
|
data={[
|
||||||
{ value: "en-US", label: "English (US)" },
|
{ value: "en-US", label: "English (US)" },
|
||||||
{ value: "es-ES", label: "Español (Spanish)" },
|
|
||||||
{ value: "de-DE", label: "Deutsch (German)" },
|
{ value: "de-DE", label: "Deutsch (German)" },
|
||||||
{ value: "fr-FR", label: "Français (French)" },
|
|
||||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||||
|
{ value: "fr-FR", label: "Français (French)" },
|
||||||
|
{ value: "es-ES", label: "Español (Spanish)" },
|
||||||
{ value: "pt-BR", label: "Português (Brasil)" },
|
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||||
{ value: "it-IT", label: "Italiano (Italian)" },
|
{ value: "it-IT", label: "Italiano (Italian)" },
|
||||||
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
||||||
{ value: "ko-KR", label: "한국어 (Korean)" },
|
{ value: "ko-KR", label: "한국어 (Korean)" },
|
||||||
{ value: "uk-UA", label: "Українська (Ukrainian)" },
|
|
||||||
{ value: "ru-RU", label: "Русский (Russian)" },
|
{ value: "ru-RU", label: "Русский (Russian)" },
|
||||||
{ value: "zh-CN", label: "中文 (简体)" },
|
{ value: "zh-CN", label: "中文 (简体)" },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1).max(40),
|
name: z.string().min(2).max(40),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
|
|
||||||
export default function PageStatePref() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Default page edit mode")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PageStateSegmentedControl />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageStateSegmentedControlProps {
|
|
||||||
size?: MantineSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageStateSegmentedControl({
|
|
||||||
size,
|
|
||||||
}: PageStateSegmentedControlProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [user, setUser] = useAtom(userAtom);
|
|
||||||
const pageEditMode =
|
|
||||||
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
const [value, setValue] = useState(pageEditMode);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
async (value: string) => {
|
|
||||||
const updatedUser = await updateUser({ pageEditMode: value });
|
|
||||||
setValue(value);
|
|
||||||
setUser(updatedUser);
|
|
||||||
},
|
|
||||||
[user, setUser],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pageEditMode !== value) {
|
|
||||||
setValue(pageEditMode);
|
|
||||||
}
|
|
||||||
}, [pageEditMode, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SegmentedControl
|
|
||||||
size={size}
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
data={[
|
|
||||||
{ label: t("Edit"), value: PageEditMode.Edit },
|
|
||||||
{ label: t("Read"), value: PageEditMode.Read },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -19,7 +19,6 @@ export interface IUser {
|
|||||||
deactivatedAt: Date;
|
deactivatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
fullPageWidth: boolean; // used for update
|
fullPageWidth: boolean; // used for update
|
||||||
pageEditMode: string; // used for update
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICurrentUser {
|
export interface ICurrentUser {
|
||||||
@ -30,11 +29,5 @@ export interface ICurrentUser {
|
|||||||
export interface IUserSettings {
|
export interface IUserSettings {
|
||||||
preferences: {
|
preferences: {
|
||||||
fullPageWidth: boolean;
|
fullPageWidth: boolean;
|
||||||
pageEditMode: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PageEditMode {
|
|
||||||
Read = "read",
|
|
||||||
Edit = "edit",
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
|
||||||
|
|
||||||
export type InvalidateEvent = {
|
export type InvalidateEvent = {
|
||||||
operation: "invalidate";
|
operation: "invalidate";
|
||||||
@ -18,7 +17,7 @@ export type UpdateEvent = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
entity: Array<string>;
|
entity: Array<string>;
|
||||||
id: string;
|
id: string;
|
||||||
payload: Partial<IPage>;
|
payload: Partial<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteEvent = {
|
export type DeleteEvent = {
|
||||||
@ -26,7 +25,7 @@ export type DeleteEvent = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
entity: Array<string>;
|
entity: Array<string>;
|
||||||
id: string;
|
id: string;
|
||||||
payload?: Partial<IPage>;
|
payload?: Partial<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AddTreeNodeEvent = {
|
export type AddTreeNodeEvent = {
|
||||||
@ -47,28 +46,15 @@ export type MoveTreeNodeEvent = {
|
|||||||
parentId: string;
|
parentId: string;
|
||||||
index: number;
|
index: number;
|
||||||
position: string;
|
position: string;
|
||||||
};
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteTreeNodeEvent = {
|
export type DeleteTreeNodeEvent = {
|
||||||
operation: "deleteTreeNode";
|
operation: "deleteTreeNode";
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
payload: {
|
payload: {
|
||||||
node: SpaceTreeNode;
|
node: SpaceTreeNode
|
||||||
};
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RefetchRootTreeNodeEvent = {
|
export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||||
operation: "refetchRootTreeNodeEvent";
|
|
||||||
spaceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebSocketEvent =
|
|
||||||
| InvalidateEvent
|
|
||||||
| InvalidateCommentsEvent
|
|
||||||
| UpdateEvent
|
|
||||||
| DeleteEvent
|
|
||||||
| AddTreeNodeEvent
|
|
||||||
| MoveTreeNodeEvent
|
|
||||||
| DeleteTreeNodeEvent
|
|
||||||
| RefetchRootTreeNodeEvent;
|
|
||||||
|
|||||||
@ -1,18 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { InfiniteData, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { WebSocketEvent } from "@/features/websocket/types";
|
import { WebSocketEvent } from "@/features/websocket/types";
|
||||||
import { IPage } from "../page/types/page.types";
|
|
||||||
import { IPagination } from "@/lib/types";
|
|
||||||
import {
|
|
||||||
invalidateOnCreatePage,
|
|
||||||
invalidateOnDeletePage,
|
|
||||||
invalidateOnMovePage,
|
|
||||||
invalidateOnUpdatePage,
|
|
||||||
} from "../page/queries/page-query";
|
|
||||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||||
import { queryClient } from "@/main.tsx";
|
|
||||||
|
|
||||||
export const useQuerySubscription = () => {
|
export const useQuerySubscription = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -36,15 +27,6 @@ export const useQuerySubscription = () => {
|
|||||||
queryKey: RQ_KEY(data.pageId),
|
queryKey: RQ_KEY(data.pageId),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "addTreeNode":
|
|
||||||
invalidateOnCreatePage(data.payload.data);
|
|
||||||
break;
|
|
||||||
case "moveTreeNode":
|
|
||||||
invalidateOnMovePage();
|
|
||||||
break;
|
|
||||||
case "deleteTreeNode":
|
|
||||||
invalidateOnDeletePage(data.payload.node.id);
|
|
||||||
break;
|
|
||||||
case "updateOne":
|
case "updateOne":
|
||||||
entity = data.entity[0];
|
entity = data.entity[0];
|
||||||
if (entity === "pages") {
|
if (entity === "pages") {
|
||||||
@ -55,23 +37,13 @@ export const useQuerySubscription = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// only update if data was already in cache
|
// only update if data was already in cache
|
||||||
if (queryClient.getQueryData([...data.entity, queryKeyId])) {
|
if(queryClient.getQueryData([...data.entity, queryKeyId])){
|
||||||
queryClient.setQueryData([...data.entity, queryKeyId], {
|
queryClient.setQueryData([...data.entity, queryKeyId], {
|
||||||
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
||||||
...data.payload,
|
...data.payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity === "pages") {
|
|
||||||
invalidateOnUpdatePage(
|
|
||||||
data.spaceId,
|
|
||||||
data.payload.parentPageId,
|
|
||||||
data.id,
|
|
||||||
data.payload.title,
|
|
||||||
data.payload.icon,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
queryClient.setQueriesData(
|
queryClient.setQueriesData(
|
||||||
{ queryKey: [data.entity, data.id] },
|
{ queryKey: [data.entity, data.id] },
|
||||||
@ -85,17 +57,6 @@ export const useQuerySubscription = () => {
|
|||||||
);
|
);
|
||||||
*/
|
*/
|
||||||
break;
|
break;
|
||||||
case "refetchRootTreeNodeEvent": {
|
|
||||||
const spaceId = data.spaceId;
|
|
||||||
queryClient.refetchQueries({
|
|
||||||
queryKey: ["root-sidebar-pages", spaceId],
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", spaceId],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [queryClient, socket]);
|
}, [queryClient, socket]);
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(4),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|||||||
@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() {
|
|||||||
|
|
||||||
export function useGetInvitationQuery(
|
export function useGetInvitationQuery(
|
||||||
invitationId: string,
|
invitationId: string,
|
||||||
): UseQueryResult<IInvitation, Error> {
|
): UseQueryResult<any, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["invitations", invitationId],
|
queryKey: ["invitations", invitationId],
|
||||||
queryFn: () => getInvitationById({ invitationId }),
|
queryFn: () => getInvitationById({ invitationId }),
|
||||||
|
|||||||
@ -12,7 +12,6 @@ export interface IWorkspace {
|
|||||||
settings: any;
|
settings: any;
|
||||||
status: string;
|
status: string;
|
||||||
enforceSso: boolean;
|
enforceSso: boolean;
|
||||||
stripeCustomerId: string;
|
|
||||||
billingEmail: string;
|
billingEmail: string;
|
||||||
trialEndAt: Date;
|
trialEndAt: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -36,7 +35,6 @@ export interface IInvitation {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
invitedById: string;
|
invitedById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
enforceSso: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInvitationLink {
|
export interface IInvitationLink {
|
||||||
|
|||||||
@ -70,11 +70,6 @@ export function getFileUploadSizeLimit() {
|
|||||||
return bytes(limit);
|
return bytes(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileImportSizeLimit() {
|
|
||||||
const limit = getConfigValue("FILE_IMPORT_SIZE_LIMIT", "200mb");
|
|
||||||
return bytes(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDrawioUrl() {
|
export function getDrawioUrl() {
|
||||||
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
||||||
}
|
}
|
||||||
@ -83,18 +78,6 @@ export function getBillingTrialDays() {
|
|||||||
return getConfigValue("BILLING_TRIAL_DAYS");
|
return getConfigValue("BILLING_TRIAL_DAYS");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPostHogHost() {
|
|
||||||
return getConfigValue("POSTHOG_HOST");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPostHogEnabled(): boolean {
|
|
||||||
return Boolean(getPostHogHost() && getPostHogKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPostHogKey() {
|
|
||||||
return getConfigValue("POSTHOG_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfigValue(key: string, defaultValue: string = undefined): string {
|
function getConfigValue(key: string, defaultValue: string = undefined): string {
|
||||||
const rawValue = import.meta.env.DEV
|
const rawValue = import.meta.env.DEV
|
||||||
? process?.env?.[key]
|
? process?.env?.[key]
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
|
|||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { mantineCssResolver, theme } from "@/theme";
|
import { mantineCssResolver, theme } from '@/theme';
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
@ -11,14 +11,6 @@ import { Notifications } from "@mantine/notifications";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
|
||||||
import {
|
|
||||||
getPostHogHost,
|
|
||||||
getPostHogKey,
|
|
||||||
isCloud,
|
|
||||||
isPostHogEnabled,
|
|
||||||
} from "@/lib/config.ts";
|
|
||||||
import posthog from "posthog-js";
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -31,17 +23,9 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
|
||||||
posthog.init(getPostHogKey(), {
|
|
||||||
api_host: getPostHogHost(),
|
|
||||||
defaults: "2025-05-24",
|
|
||||||
disable_session_recording: true,
|
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById("root") as HTMLElement,
|
document.getElementById("root") as HTMLElement
|
||||||
);
|
);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
@ -51,12 +35,10 @@ root.render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Notifications position="bottom-center" limit={3} />
|
<Notifications position="bottom-center" limit={3} />
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<PostHogProvider client={posthog}>
|
<App />
|
||||||
<App />
|
|
||||||
</PostHogProvider>
|
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,11 +12,6 @@ import {
|
|||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const MemoizedFullEditor = React.memo(FullEditor);
|
|
||||||
const MemoizedPageHeader = React.memo(PageHeader);
|
|
||||||
const MemoizedHistoryModal = React.memo(HistoryModal);
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -54,14 +49,14 @@ export default function Page() {
|
|||||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<MemoizedPageHeader
|
<PageHeader
|
||||||
readOnly={spaceAbility.cannot(
|
readOnly={spaceAbility.cannot(
|
||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MemoizedFullEditor
|
<FullEditor
|
||||||
key={page.id}
|
key={page.id}
|
||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
title={page.title}
|
title={page.title}
|
||||||
@ -73,7 +68,7 @@ export default function Page() {
|
|||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<HistoryModal pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
|
|||||||
import AccountLanguage from "@/features/user/components/account-language.tsx";
|
import AccountLanguage from "@/features/user/components/account-language.tsx";
|
||||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
|
||||||
import { getAppName } from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import { Divider } from "@mantine/core";
|
import { Divider } from "@mantine/core";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
@ -29,10 +28,6 @@ export default function AccountPreferences() {
|
|||||||
<Divider my={"md"} />
|
<Divider my={"md"} />
|
||||||
|
|
||||||
<PageWidthPref />
|
<PageWidthPref />
|
||||||
|
|
||||||
<Divider my={"md"} />
|
|
||||||
|
|
||||||
<PageEditPref />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ import React, { useEffect } from "react";
|
|||||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
|
||||||
|
|
||||||
export default function SharedPage() {
|
export default function SingleSharedPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@ -54,8 +53,6 @@ export default function SharedPage() {
|
|||||||
content={data.page.content}
|
content={data.page.content}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{data && !shareId && !data.hasLicenseKey && <ShareBranding />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,14 +8,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
FILE_IMPORT_SIZE_LIMIT,
|
|
||||||
DRAWIO_URL,
|
DRAWIO_URL,
|
||||||
CLOUD,
|
CLOUD,
|
||||||
SUBDOMAIN_HOST,
|
SUBDOMAIN_HOST,
|
||||||
COLLAB_URL,
|
COLLAB_URL,
|
||||||
BILLING_TRIAL_DAYS,
|
BILLING_TRIAL_DAYS,
|
||||||
POSTHOG_HOST,
|
|
||||||
POSTHOG_KEY,
|
|
||||||
} = loadEnv(mode, envPath, "");
|
} = loadEnv(mode, envPath, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -23,14 +20,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
"process.env": {
|
"process.env": {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
FILE_IMPORT_SIZE_LIMIT,
|
|
||||||
DRAWIO_URL,
|
DRAWIO_URL,
|
||||||
CLOUD,
|
CLOUD,
|
||||||
SUBDOMAIN_HOST,
|
SUBDOMAIN_HOST,
|
||||||
COLLAB_URL,
|
COLLAB_URL,
|
||||||
BILLING_TRIAL_DAYS,
|
BILLING_TRIAL_DAYS,
|
||||||
POSTHOG_HOST,
|
|
||||||
POSTHOG_KEY,
|
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.21.0",
|
"version": "0.20.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -31,60 +31,56 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.701.0",
|
"@aws-sdk/client-s3": "3.701.0",
|
||||||
"@aws-sdk/lib-storage": "3.701.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||||
"@casl/ability": "^6.7.3",
|
"@casl/ability": "^6.7.3",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.0.3",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.1.1",
|
||||||
"@nestjs/bullmq": "^11.0.2",
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^11.1.3",
|
"@nestjs/common": "^11.0.20",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.3",
|
"@nestjs/core": "^11.0.20",
|
||||||
"@nestjs/event-emitter": "^3.0.1",
|
"@nestjs/event-emitter": "^3.0.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^11.1.3",
|
"@nestjs/platform-fastify": "^11.0.20",
|
||||||
"@nestjs/platform-socket.io": "^11.1.3",
|
"@nestjs/platform-socket.io": "^11.0.20",
|
||||||
"@nestjs/schedule": "^6.0.0",
|
"@nestjs/schedule": "^5.0.1",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.3",
|
"@nestjs/websockets": "^11.0.20",
|
||||||
"@node-saml/passport-saml": "^5.0.1",
|
"@node-saml/passport-saml": "^5.0.1",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "0.0.28",
|
||||||
"@react-email/render": "1.0.2",
|
"@react-email/render": "1.0.2",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.53.2",
|
"bullmq": "^5.41.3",
|
||||||
"cache-manager": "^6.4.3",
|
"cache-manager": "^6.4.0",
|
||||||
"cheerio": "^1.1.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"happy-dom": "^15.11.6",
|
"happy-dom": "^15.11.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.27.5",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.1.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^6.10.0",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.13.3",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename-ts": "^1.0.2",
|
"sanitize-filename-ts": "^1.0.2",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^17.5.0",
|
"stripe": "^17.5.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"ws": "^8.18.0"
|
||||||
"ws": "^8.18.2",
|
|
||||||
"yauzl": "^3.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.20.0",
|
"@eslint/js": "^9.20.0",
|
||||||
@ -103,7 +99,6 @@
|
|||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yauzl": "^2.10.3",
|
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.20.1",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
|
|||||||
@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
);
|
);
|
||||||
this.contributors.delete(documentName);
|
this.contributors.delete(documentName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
|
||||||
|
|
||||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||||
|
|
||||||
@ -16,12 +15,6 @@ export async function comparePasswordHash(
|
|||||||
return bcrypt.compare(plainPassword, passwordHash);
|
return bcrypt.compare(plainPassword, passwordHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRandomSuffixNumbers(length: number) {
|
|
||||||
return Math.random()
|
|
||||||
.toFixed(length)
|
|
||||||
.substring(2, 2 + length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RedisConfig = {
|
export type RedisConfig = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@ -69,8 +62,3 @@ export function extractDateFromUuid7(uuid7: string) {
|
|||||||
|
|
||||||
return new Date(timestamp);
|
return new Date(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeFileName(fileName: string): string {
|
|
||||||
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
|
||||||
return sanitizedFilename.slice(0, 255);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -21,6 +23,7 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
|||||||
import { PasswordResetDto } from './dto/password-reset.dto';
|
import { PasswordResetDto } from './dto/password-reset.dto';
|
||||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { addDays } from 'date-fns';
|
||||||
import { validateSsoEnforcement } from './auth.util';
|
import { validateSsoEnforcement } from './auth.util';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -122,7 +125,7 @@ export class AuthController {
|
|||||||
res.setCookie('authToken', token, {
|
res.setCookie('authToken', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: this.environmentService.getCookieExpiresIn(),
|
expires: addDays(new Date(), 30),
|
||||||
secure: this.environmentService.isHttps(),
|
secure: this.environmentService.isHttps(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,3 @@ export function validateSsoEnforcement(workspace: Workspace) {
|
|||||||
throw new BadRequestException('This workspace has enforced SSO login.');
|
throw new BadRequestException('This workspace has enforced SSO login.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateAllowedEmail(userEmail: string, workspace: Workspace) {
|
|
||||||
const emailParts = userEmail.split('@');
|
|
||||||
const emailDomain = emailParts[1].toLowerCase();
|
|
||||||
if (
|
|
||||||
workspace.emailDomains?.length > 0 &&
|
|
||||||
!workspace.emailDomains.includes(emailDomain)
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`The email domain "${emailDomain}" is not approved for this workspace.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import {
|
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
MaxLength,
|
|
||||||
MinLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { CreateUserDto } from './create-user.dto';
|
import { CreateUserDto } from './create-user.dto';
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import {Transform, TransformFnParams} from "class-transformer";
|
||||||
|
|
||||||
export class CreateAdminUserDto extends CreateUserDto {
|
export class CreateAdminUserDto extends CreateUserDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -15,17 +9,10 @@ export class CreateAdminUserDto extends CreateUserDto {
|
|||||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
@MinLength(1)
|
@MinLength(3)
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@MinLength(4)
|
|
||||||
@MaxLength(50)
|
|
||||||
@IsString()
|
|
||||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
|
||||||
hostname?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,8 +92,7 @@ export class SignupService {
|
|||||||
|
|
||||||
// create workspace with full setup
|
// create workspace with full setup
|
||||||
const workspaceData: CreateWorkspaceDto = {
|
const workspaceData: CreateWorkspaceDto = {
|
||||||
name: createAdminUserDto.workspaceName || 'My workspace',
|
name: createAdminUserDto.workspaceName,
|
||||||
hostname: createAdminUserDto.hostname,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
workspace = await this.workspaceService.create(
|
workspace = await this.workspaceService.create(
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('shares')
|
@Controller('shares')
|
||||||
@ -40,7 +39,6 @@ export class ShareController {
|
|||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly shareRepo: ShareRepo,
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -63,12 +61,7 @@ export class ShareController {
|
|||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return this.shareService.getSharedPage(dto, workspace.id);
|
||||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
|
||||||
hasLicenseKey:
|
|
||||||
Boolean(workspace.licenseKey) ||
|
|
||||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -173,11 +166,6 @@ export class ShareController {
|
|||||||
@Body() dto: ShareIdDto,
|
@Body() dto: ShareIdDto,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return {
|
return this.shareService.getShareTree(dto.shareId, workspace.id);
|
||||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
|
||||||
hasLicenseKey:
|
|
||||||
Boolean(workspace.licenseKey) ||
|
|
||||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||||
import {
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||||
IsBoolean,
|
|
||||||
IsIn,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
MaxLength,
|
|
||||||
MinLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
||||||
|
|
||||||
export class UpdateUserDto extends PartialType(
|
export class UpdateUserDto extends PartialType(
|
||||||
@ -21,18 +13,7 @@ export class UpdateUserDto extends PartialType(
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
fullPageWidth: boolean;
|
fullPageWidth: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(['read', 'edit'])
|
|
||||||
pageEditMode: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
locale: string;
|
locale: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@MinLength(8)
|
|
||||||
@MaxLength(70)
|
|
||||||
@IsString()
|
|
||||||
confirmPassword: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,6 @@ export class UserController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.userService.update(updateUserDto, user.id, workspace);
|
return this.userService.update(updateUserDto, user.id, workspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,8 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { comparePasswordHash } from 'src/common/helpers/utils';
|
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { validateSsoEnforcement } from '../auth/auth.util';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@ -21,14 +17,9 @@ export class UserService {
|
|||||||
async update(
|
async update(
|
||||||
updateUserDto: UpdateUserDto,
|
updateUserDto: UpdateUserDto,
|
||||||
userId: string,
|
userId: string,
|
||||||
workspace: Workspace,
|
workspaceId: string,
|
||||||
) {
|
) {
|
||||||
const includePassword =
|
const user = await this.userRepo.findById(userId, workspaceId);
|
||||||
updateUserDto.email != null && updateUserDto.confirmPassword != null;
|
|
||||||
|
|
||||||
const user = await this.userRepo.findById(userId, workspace.id, {
|
|
||||||
includePassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
@ -43,40 +34,14 @@ export class UserService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof updateUserDto.pageEditMode !== 'undefined') {
|
|
||||||
return this.userRepo.updatePreference(
|
|
||||||
userId,
|
|
||||||
'pageEditMode',
|
|
||||||
updateUserDto.pageEditMode.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateUserDto.name) {
|
if (updateUserDto.name) {
|
||||||
user.name = updateUserDto.name;
|
user.name = updateUserDto.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateUserDto.email && user.email != updateUserDto.email) {
|
if (updateUserDto.email && user.email != updateUserDto.email) {
|
||||||
validateSsoEnforcement(workspace);
|
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
|
||||||
|
|
||||||
if (!updateUserDto.confirmPassword) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'You must provide a password to change your email',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPasswordMatch = await comparePasswordHash(
|
|
||||||
updateUserDto.confirmPassword,
|
|
||||||
user.password,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isPasswordMatch) {
|
|
||||||
throw new BadRequestException('You must provide the correct password to change your email');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
|
|
||||||
throw new BadRequestException('A user with this email already exists');
|
throw new BadRequestException('A user with this email already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
user.email = updateUserDto.email;
|
user.email = updateUserDto.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,9 +53,7 @@ export class UserService {
|
|||||||
user.locale = updateUserDto.locale;
|
user.locale = updateUserDto.locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete updateUserDto.confirmPassword;
|
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
|
||||||
|
|
||||||
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,9 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
|
|||||||
import {
|
import {
|
||||||
WorkspaceCaslAction,
|
WorkspaceCaslAction,
|
||||||
WorkspaceCaslSubject,
|
WorkspaceCaslSubject,
|
||||||
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
|
} from '../../casl/interfaces/workspace-ability.type';
|
||||||
|
import { addDays } from 'date-fns';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||||
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||||
@ -178,13 +180,10 @@ export class WorkspaceController {
|
|||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('invites/info')
|
@Post('invites/info')
|
||||||
async getInvitationById(
|
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
|
||||||
@Body() dto: InvitationIdDto,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
return this.workspaceInvitationService.getInvitationById(
|
return this.workspaceInvitationService.getInvitationById(
|
||||||
dto.invitationId,
|
dto.invitationId,
|
||||||
workspace,
|
req.raw.workspaceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,18 +253,18 @@ export class WorkspaceController {
|
|||||||
@Post('invites/accept')
|
@Post('invites/accept')
|
||||||
async acceptInvite(
|
async acceptInvite(
|
||||||
@Body() acceptInviteDto: AcceptInviteDto,
|
@Body() acceptInviteDto: AcceptInviteDto,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@Req() req: any,
|
||||||
@Res({ passthrough: true }) res: FastifyReply,
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
||||||
acceptInviteDto,
|
acceptInviteDto,
|
||||||
workspace,
|
req.raw.workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.setCookie('authToken', authToken, {
|
res.setCookie('authToken', authToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: this.environmentService.getCookieExpiresIn(),
|
expires: addDays(new Date(), 30),
|
||||||
secure: this.environmentService.isHttps(),
|
secure: this.environmentService.isHttps(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,10 +28,6 @@ import { InjectQueue } from '@nestjs/bullmq';
|
|||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import {
|
|
||||||
validateAllowedEmail,
|
|
||||||
validateSsoEnforcement,
|
|
||||||
} from '../../auth/auth.util';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceInvitationService {
|
export class WorkspaceInvitationService {
|
||||||
@ -67,19 +63,19 @@ export class WorkspaceInvitationService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInvitationById(invitationId: string, workspace: Workspace) {
|
async getInvitationById(invitationId: string, workspaceId: string) {
|
||||||
const invitation = await this.db
|
const invitation = await this.db
|
||||||
.selectFrom('workspaceInvitations')
|
.selectFrom('workspaceInvitations')
|
||||||
.select(['id', 'email', 'createdAt'])
|
.select(['id', 'email', 'createdAt'])
|
||||||
.where('id', '=', invitationId)
|
.where('id', '=', invitationId)
|
||||||
.where('workspaceId', '=', workspace.id)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
throw new NotFoundException('Invitation not found');
|
throw new NotFoundException('Invitation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...invitation, enforceSso: workspace.enforceSso };
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInvitationTokenById(invitationId: string, workspaceId: string) {
|
async getInvitationTokenById(invitationId: string, workspaceId: string) {
|
||||||
@ -145,10 +141,6 @@ export class WorkspaceInvitationService {
|
|||||||
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
|
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (invitesToInsert.length < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
invites = await trx
|
invites = await trx
|
||||||
.insertInto('workspaceInvitations')
|
.insertInto('workspaceInvitations')
|
||||||
.values(invitesToInsert)
|
.values(invitesToInsert)
|
||||||
@ -177,12 +169,12 @@ export class WorkspaceInvitationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
|
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
|
||||||
const invitation = await this.db
|
const invitation = await this.db
|
||||||
.selectFrom('workspaceInvitations')
|
.selectFrom('workspaceInvitations')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('id', '=', dto.invitationId)
|
.where('id', '=', dto.invitationId)
|
||||||
.where('workspaceId', '=', workspace.id)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
@ -193,9 +185,6 @@ export class WorkspaceInvitationService {
|
|||||||
throw new BadRequestException('Invalid invitation token');
|
throw new BadRequestException('Invalid invitation token');
|
||||||
}
|
}
|
||||||
|
|
||||||
validateSsoEnforcement(workspace);
|
|
||||||
validateAllowedEmail(invitation.email, workspace);
|
|
||||||
|
|
||||||
let newUser: User;
|
let newUser: User;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -208,7 +197,7 @@ export class WorkspaceInvitationService {
|
|||||||
password: dto.password,
|
password: dto.password,
|
||||||
role: invitation.role,
|
role: invitation.role,
|
||||||
invitedById: invitation.invitedById,
|
invitedById: invitation.invitedById,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspaceId,
|
||||||
},
|
},
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
@ -216,7 +205,7 @@ export class WorkspaceInvitationService {
|
|||||||
// add user to default group
|
// add user to default group
|
||||||
await this.groupUserRepo.addUserToDefaultGroup(
|
await this.groupUserRepo.addUserToDefaultGroup(
|
||||||
newUser.id,
|
newUser.id,
|
||||||
workspace.id,
|
workspaceId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -226,7 +215,7 @@ export class WorkspaceInvitationService {
|
|||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.select(['id', 'name'])
|
.select(['id', 'name'])
|
||||||
.where('groups.id', 'in', invitation.groupIds)
|
.where('groups.id', 'in', invitation.groupIds)
|
||||||
.where('groups.workspaceId', '=', workspace.id)
|
.where('groups.workspaceId', '=', workspaceId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
if (validGroups && validGroups.length > 0) {
|
if (validGroups && validGroups.length > 0) {
|
||||||
@ -267,7 +256,7 @@ export class WorkspaceInvitationService {
|
|||||||
// notify the inviter
|
// notify the inviter
|
||||||
const invitedByUser = await this.userRepo.findById(
|
const invitedByUser = await this.userRepo.findById(
|
||||||
invitation.invitedById,
|
invitation.invitedById,
|
||||||
workspace.id,
|
workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invitedByUser) {
|
if (invitedByUser) {
|
||||||
@ -284,9 +273,7 @@ export class WorkspaceInvitationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.environmentService.isCloud()) {
|
if (this.environmentService.isCloud()) {
|
||||||
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
|
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId });
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tokenService.generateAccessToken(newUser);
|
return this.tokenService.generateAccessToken(newUser);
|
||||||
|
|||||||
@ -32,7 +32,6 @@ import { AttachmentType } from 'src/core/attachment/attachment.constants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
@ -378,20 +377,24 @@ export class WorkspaceService {
|
|||||||
name: string,
|
name: string,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const generateRandomSuffix = (length: number) =>
|
||||||
|
Math.random()
|
||||||
|
.toFixed(length)
|
||||||
|
.substring(2, 2 + length);
|
||||||
|
|
||||||
let subdomain = name
|
let subdomain = name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9-]/g, '')
|
.replace(/[^a-z0-9]/g, '')
|
||||||
.substring(0, 20)
|
.substring(0, 20);
|
||||||
.replace(/^-+|-+$/g, ''); //remove any hyphen at the start or end
|
|
||||||
// Ensure we leave room for a random suffix.
|
// Ensure we leave room for a random suffix.
|
||||||
const maxSuffixLength = 6;
|
const maxSuffixLength = 6;
|
||||||
|
|
||||||
if (subdomain.length < 4) {
|
if (subdomain.length < 4) {
|
||||||
subdomain = `${subdomain}-${generateRandomSuffixNumbers(maxSuffixLength)}`;
|
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
||||||
subdomain = `workspace-${generateRandomSuffixNumbers(maxSuffixLength)}`;
|
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let uniqueHostname = subdomain;
|
let uniqueHostname = subdomain;
|
||||||
@ -405,7 +408,7 @@ export class WorkspaceService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Append a random suffix and retry.
|
// Append a random suffix and retry.
|
||||||
const randomSuffix = generateRandomSuffixNumbers(maxSuffixLength);
|
const randomSuffix = generateRandomSuffix(maxSuffixLength);
|
||||||
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
|
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('file_tasks')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
// type (import, export)
|
|
||||||
.addColumn('type', 'varchar', (col) => col)
|
|
||||||
// source (generic, notion, confluence)
|
|
||||||
.addColumn('source', 'varchar', (col) => col)
|
|
||||||
// status (pending|processing|success|failed),
|
|
||||||
.addColumn('status', 'varchar', (col) => col)
|
|
||||||
.addColumn('file_name', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('file_path', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('file_size', 'int8', (col) => col)
|
|
||||||
.addColumn('file_ext', 'varchar', (col) => col)
|
|
||||||
.addColumn('error_message', 'varchar', (col) => col)
|
|
||||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
|
||||||
.addColumn('space_id', 'uuid', (col) =>
|
|
||||||
col.references('spaces.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.dropTable('file_tasks').execute();
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { type Kysely } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.alterTable('billing')
|
|
||||||
.addColumn('billing_scheme', 'varchar', (col) => col)
|
|
||||||
.addColumn('tiered_up_to', 'varchar', (col) => col)
|
|
||||||
.addColumn('tiered_flat_amount', 'int8', (col) => col)
|
|
||||||
.addColumn('tiered_unit_amount', 'int8', (col) => col)
|
|
||||||
.addColumn('plan_name', 'varchar', (col) => col)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.alterTable('billing')
|
|
||||||
.dropColumn('billing_scheme')
|
|
||||||
.dropColumn('tiered_up_to')
|
|
||||||
.dropColumn('tiered_flat_amount')
|
|
||||||
.dropColumn('tiered_unit_amount')
|
|
||||||
.dropColumn('plan_name')
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
24
apps/server/src/database/types/db.d.ts
vendored
24
apps/server/src/database/types/db.d.ts
vendored
@ -84,7 +84,6 @@ export interface Backlinks {
|
|||||||
|
|
||||||
export interface Billing {
|
export interface Billing {
|
||||||
amount: Int8 | null;
|
amount: Int8 | null;
|
||||||
billingScheme: string | null;
|
|
||||||
cancelAt: Timestamp | null;
|
cancelAt: Timestamp | null;
|
||||||
cancelAtPeriodEnd: boolean | null;
|
cancelAtPeriodEnd: boolean | null;
|
||||||
canceledAt: Timestamp | null;
|
canceledAt: Timestamp | null;
|
||||||
@ -97,7 +96,6 @@ export interface Billing {
|
|||||||
metadata: Json | null;
|
metadata: Json | null;
|
||||||
periodEndAt: Timestamp | null;
|
periodEndAt: Timestamp | null;
|
||||||
periodStartAt: Timestamp;
|
periodStartAt: Timestamp;
|
||||||
planName: string | null;
|
|
||||||
quantity: Int8 | null;
|
quantity: Int8 | null;
|
||||||
status: string;
|
status: string;
|
||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
@ -105,9 +103,6 @@ export interface Billing {
|
|||||||
stripePriceId: string | null;
|
stripePriceId: string | null;
|
||||||
stripeProductId: string | null;
|
stripeProductId: string | null;
|
||||||
stripeSubscriptionId: string;
|
stripeSubscriptionId: string;
|
||||||
tieredFlatAmount: Int8 | null;
|
|
||||||
tieredUnitAmount: Int8 | null;
|
|
||||||
tieredUpTo: string | null;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
@ -127,24 +122,6 @@ export interface Comments {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileTasks {
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
creatorId: string | null;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
fileExt: string | null;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
fileSize: Int8 | null;
|
|
||||||
id: Generated<string>;
|
|
||||||
source: string | null;
|
|
||||||
spaceId: string | null;
|
|
||||||
status: string | null;
|
|
||||||
type: string | null;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Groups {
|
export interface Groups {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
@ -321,7 +298,6 @@ export interface DB {
|
|||||||
backlinks: Backlinks;
|
backlinks: Backlinks;
|
||||||
billing: Billing;
|
billing: Billing;
|
||||||
comments: Comments;
|
comments: Comments;
|
||||||
fileTasks: FileTasks;
|
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import {
|
|||||||
AuthProviders,
|
AuthProviders,
|
||||||
AuthAccounts,
|
AuthAccounts,
|
||||||
Shares,
|
Shares,
|
||||||
FileTasks,
|
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
@ -108,8 +107,3 @@ export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
|
|||||||
export type Share = Selectable<Shares>;
|
export type Share = Selectable<Shares>;
|
||||||
export type InsertableShare = Insertable<Shares>;
|
export type InsertableShare = Insertable<Shares>;
|
||||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||||
|
|
||||||
// File Task
|
|
||||||
export type FileTask = Selectable<FileTasks>;
|
|
||||||
export type InsertableFileTask = Insertable<FileTasks>;
|
|
||||||
export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
|
|
||||||
|
|||||||
Submodule apps/server/src/ee updated: 4c252d1ec3...12f576ce72
@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import ms, { StringValue } from 'ms';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnvironmentService {
|
export class EnvironmentService {
|
||||||
@ -57,18 +56,7 @@ export class EnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getJwtTokenExpiresIn(): string {
|
getJwtTokenExpiresIn(): string {
|
||||||
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
|
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '30d');
|
||||||
}
|
|
||||||
|
|
||||||
getCookieExpiresIn(): Date {
|
|
||||||
const expiresInStr = this.getJwtTokenExpiresIn();
|
|
||||||
let msUntilExpiry: number;
|
|
||||||
try {
|
|
||||||
msUntilExpiry = ms(expiresInStr as StringValue);
|
|
||||||
} catch (err) {
|
|
||||||
msUntilExpiry = ms('90d');
|
|
||||||
}
|
|
||||||
return new Date(Date.now() + msUntilExpiry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getStorageDriver(): string {
|
getStorageDriver(): string {
|
||||||
@ -79,10 +67,6 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
|
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileImportSizeLimit(): string {
|
|
||||||
return this.configService.get<string>('FILE_IMPORT_SIZE_LIMIT', '200mb');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAwsS3AccessKeyId(): string {
|
getAwsS3AccessKeyId(): string {
|
||||||
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
|
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
|
||||||
}
|
}
|
||||||
@ -205,12 +189,4 @@ export class EnvironmentService {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return disable === 'true';
|
return disable === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
getPostHogHost(): string {
|
|
||||||
return this.configService.get<string>('POSTHOG_HOST');
|
|
||||||
}
|
|
||||||
|
|
||||||
getPostHogKey(): string {
|
|
||||||
return this.configService.get<string>('POSTHOG_KEY');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as TurndownService from '@joplin/turndown';
|
import * as TurndownService from '@joplin/turndown';
|
||||||
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export function turndown(html: string): string {
|
export function turndown(html: string): string {
|
||||||
const turndownService = new TurndownService({
|
const turndownService = new TurndownService({
|
||||||
@ -24,7 +23,6 @@ export function turndown(html: string): string {
|
|||||||
mathInline,
|
mathInline,
|
||||||
mathBlock,
|
mathBlock,
|
||||||
iframeEmbed,
|
iframeEmbed,
|
||||||
video,
|
|
||||||
]);
|
]);
|
||||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||||
}
|
}
|
||||||
@ -89,12 +87,8 @@ function preserveDetail(turndownService: TurndownService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detailsContent = Array.from(node.childNodes)
|
const detailsContent = Array.from(node.childNodes)
|
||||||
.filter((child) => child.nodeName !== 'SUMMARY')
|
.filter(child => child.nodeName !== 'SUMMARY')
|
||||||
.map((child) =>
|
.map(child => (child.nodeType === 1 ? turndownService.turndown((child as HTMLElement).outerHTML) : child.textContent))
|
||||||
child.nodeType === 1
|
|
||||||
? turndownService.turndown((child as HTMLElement).outerHTML)
|
|
||||||
: child.textContent,
|
|
||||||
)
|
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
||||||
@ -141,16 +135,3 @@ function iframeEmbed(turndownService: TurndownService) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function video(turndownService: TurndownService) {
|
|
||||||
turndownService.addRule('video', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return node.tagName === 'VIDEO';
|
|
||||||
},
|
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
|
||||||
const src = node.getAttribute('src') || '';
|
|
||||||
const name = path.basename(src);
|
|
||||||
return '[' + name + '](' + src + ')';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class FileTaskIdDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsUUID()
|
|
||||||
fileTaskId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ImportPageNode = {
|
|
||||||
id: string;
|
|
||||||
slugId: string;
|
|
||||||
name: string;
|
|
||||||
content: string;
|
|
||||||
position?: string | null;
|
|
||||||
parentPageId: string | null;
|
|
||||||
fileExtension: string;
|
|
||||||
filePath: string;
|
|
||||||
};
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
NotFoundException,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from '../../core/casl/interfaces/space-ability.type';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
|
||||||
import { FileTaskIdDto } from './dto/file-task-dto';
|
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
||||||
|
|
||||||
@Controller('file-tasks')
|
|
||||||
export class FileTaskController {
|
|
||||||
constructor(
|
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post()
|
|
||||||
async getFileTasks(@AuthUser() user: User) {
|
|
||||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
|
|
||||||
|
|
||||||
if (!userSpaceIds || userSpaceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileTasks = await this.db
|
|
||||||
.selectFrom('fileTasks')
|
|
||||||
.selectAll()
|
|
||||||
.where('spaceId', 'in', userSpaceIds)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (!fileTasks) {
|
|
||||||
throw new NotFoundException('File task not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileTasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('info')
|
|
||||||
async getFileTask(@Body() dto: FileTaskIdDto, @AuthUser() user: User) {
|
|
||||||
const fileTask = await this.db
|
|
||||||
.selectFrom('fileTasks')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', dto.fileTaskId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!fileTask || !fileTask.spaceId) {
|
|
||||||
throw new NotFoundException('File task not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
fileTask.spaceId,
|
|
||||||
);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,9 +21,8 @@ import {
|
|||||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
import * as bytes from 'bytes';
|
import * as bytes from 'bytes';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { ImportService } from './services/import.service';
|
import { ImportService } from './import.service';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ImportController {
|
export class ImportController {
|
||||||
@ -32,7 +31,6 @@ export class ImportController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly importService: ImportService,
|
private readonly importService: ImportService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseInterceptors(FileInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
@ -46,18 +44,18 @@ export class ImportController {
|
|||||||
) {
|
) {
|
||||||
const validFileExtensions = ['.md', '.html'];
|
const validFileExtensions = ['.md', '.html'];
|
||||||
|
|
||||||
const maxFileSize = bytes('10mb');
|
const maxFileSize = bytes('100mb');
|
||||||
|
|
||||||
let file = null;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
file = await req.file({
|
file = await req.file({
|
||||||
limits: { fileSize: maxFileSize, fields: 4, files: 1 },
|
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(err.message);
|
this.logger.error(err.message);
|
||||||
if (err?.statusCode === 413) {
|
if (err?.statusCode === 413) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`File too large. Exceeds the 10mb import limit`,
|
`File too large. Exceeds the 100mb import limit`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -75,7 +73,7 @@ export class ImportController {
|
|||||||
const spaceId = file.fields?.spaceId?.value;
|
const spaceId = file.fields?.spaceId?.value;
|
||||||
|
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
throw new BadRequestException('spaceId is required');
|
throw new BadRequestException('spaceId or format not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
@ -85,69 +83,4 @@ export class ImportController {
|
|||||||
|
|
||||||
return this.importService.importPage(file, user.id, spaceId, workspace.id);
|
return this.importService.importPage(file, user.id, spaceId, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseInterceptors(FileInterceptor)
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('pages/import-zip')
|
|
||||||
async importZip(
|
|
||||||
@Req() req: any,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const validFileExtensions = ['.zip'];
|
|
||||||
|
|
||||||
const maxFileSize = bytes(this.environmentService.getFileImportSizeLimit());
|
|
||||||
|
|
||||||
let file = null;
|
|
||||||
try {
|
|
||||||
file = await req.file({
|
|
||||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(err.message);
|
|
||||||
if (err?.statusCode === 413) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`File too large. Exceeds the ${this.environmentService.getFileImportSizeLimit()} import limit`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
throw new BadRequestException('Failed to upload file');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('Invalid import file extension.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const spaceId = file.fields?.spaceId?.value;
|
|
||||||
const source = file.fields?.source?.value;
|
|
||||||
|
|
||||||
const validZipSources = ['generic', 'notion', 'confluence'];
|
|
||||||
if (!validZipSources.includes(source)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Invalid import source. Import source must either be generic, notion or confluence.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!spaceId) {
|
|
||||||
throw new BadRequestException('spaceId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.importService.importZip(
|
|
||||||
file,
|
|
||||||
source,
|
|
||||||
user.id,
|
|
||||||
spaceId,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ImportService } from './services/import.service';
|
import { ImportService } from './import.service';
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
|
||||||
import { FileTaskService } from './services/file-task.service';
|
|
||||||
import { FileTaskProcessor } from './processors/file-task.processor';
|
|
||||||
import { ImportAttachmentService } from './services/import-attachment.service';
|
|
||||||
import { FileTaskController } from './file-task.controller';
|
|
||||||
import { PageModule } from '../../core/page/page.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [ImportService],
|
||||||
ImportService,
|
controllers: [ImportController],
|
||||||
FileTaskService,
|
|
||||||
FileTaskProcessor,
|
|
||||||
ImportAttachmentService,
|
|
||||||
],
|
|
||||||
exports: [ImportService, ImportAttachmentService],
|
|
||||||
controllers: [ImportController, FileTaskController],
|
|
||||||
imports: [StorageModule, PageModule],
|
|
||||||
})
|
})
|
||||||
export class ImportModule {}
|
export class ImportModule {}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user