mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 16:42:05 +10:00
Compare commits
42 Commits
optional-s
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
| e96cf0ed46 | |||
| 29388636bf | |||
| f80004817c | |||
| ac79a185de | |||
| 27a9c0ebe4 | |||
| 81ffa6f459 | |||
| 5364702b69 | |||
| 232cea8cc9 | |||
| b9643d3584 | |||
| 9f144d35fb | |||
| e44c170873 | |||
| 1be39d4353 | |||
| 36d028ef4d | |||
| f5a36c60e8 | |||
| d5b84ae0b8 | |||
| e775e4dd8c | |||
| 65b01038d7 | |||
| e07cb57b01 | |||
| 2b53e0a455 | |||
| b9b3406b28 | |||
| 728cac0a34 | |||
| d35e16010b | |||
| 15791d4e59 | |||
| 3318e13225 | |||
| 080900610d | |||
| d1dc6977ab | |||
| 5f62448894 | |||
| 44445fbf46 | |||
| 1c674efddd | |||
| ccf7e34e99 | |||
| f39d48d6ee | |||
| f584ea84b0 | |||
| bc0c4d6258 | |||
| d8da307a61 | |||
| 50b3f9ddd9 | |||
| 0029f84d50 | |||
| 6d024fc3de | |||
| ce1503af85 | |||
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.20.4",
|
||||
"version": "0.21.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -15,44 +15,47 @@
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@mantine/spotlight": "^7.17.0",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"axios": "^1.8.4",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.1",
|
||||
"jotai": "^2.12.5",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.21",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"katex": "0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-clear-modal": "^2.0.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"semver": "^7.7.1",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.25.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
@ -76,6 +79,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.3.2"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||
"Share not found": "Freigabe nicht gefunden",
|
||||
"Failed to share page": "Fehler beim Teilen der Seite"
|
||||
"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,6 +354,9 @@
|
||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||
"New update": "New update",
|
||||
"{{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",
|
||||
"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.",
|
||||
@ -384,7 +387,17 @@
|
||||
"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": "Copy page",
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||
"Share not found": "Compartición no encontrada",
|
||||
"Failed to share page": "Error al compartir la página"
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
||||
"Share deleted successfully": "Partage supprimé avec succès",
|
||||
"Share not found": "Partage non trouvé",
|
||||
"Failed to share page": "Échec du partage de la page"
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
||||
"Share not found": "Condivisione non trovata",
|
||||
"Failed to share page": "Condivisione della pagina fallita"
|
||||
"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": "メンバーを追加しました",
|
||||
"Member removed 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}}",
|
||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||
"Share deleted successfully": "共有が正常に削除されました",
|
||||
"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 valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||
"Enter your current password": "기존 비밀번호를 입력하세요",
|
||||
"enter your full name": "전체 이름을 입력하세요",
|
||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||
@ -170,7 +170,7 @@
|
||||
"Successfully restored": "복원 완료",
|
||||
"System settings": "시스템 설정",
|
||||
"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": "전체 페이지 너비 전환",
|
||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||
"untitled": "제목 없음",
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||
"Share not found": "Delen niet gevonden",
|
||||
"Failed to share page": "Pagina delen mislukt"
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||
"Share not found": "Compartilhamento não encontrado",
|
||||
"Failed to share page": "Falha ao compartilhar página"
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
||||
"Share deleted successfully": "Общий доступ успешно удален",
|
||||
"Share not found": "Общий доступ не найден",
|
||||
"Failed to share page": "Не удалось поделиться страницей"
|
||||
"Failed to share page": "Не удалось поделиться страницей",
|
||||
"Copy page": "Копировать страницу",
|
||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
||||
"Page copied successfully": "Страница успешно скопирована"
|
||||
}
|
||||
|
||||
390
apps/client/public/locales/uk-UA/translation.json
Normal file
390
apps/client/public/locales/uk-UA/translation.json
Normal file
@ -0,0 +1,390 @@
|
||||
{
|
||||
"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,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
||||
"Share deleted successfully": "分享已成功删除",
|
||||
"Share not found": "未找到分享",
|
||||
"Failed to share page": "页面分享失败"
|
||||
"Failed to share page": "页面分享失败",
|
||||
"Copy page": "复制页面",
|
||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
||||
"Page copied successfully": "页面复制成功"
|
||||
}
|
||||
|
||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
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,6 +1,8 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
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() {
|
||||
return (
|
||||
@ -8,6 +10,7 @@ export default function Layout() {
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
||||
>
|
||||
Plan
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
{
|
||||
plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name
|
||||
}
|
||||
<Text fw={700} fz="lg" tt="capitalize">
|
||||
{plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name ||
|
||||
billing.planName ||
|
||||
"Standard"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@ -112,18 +112,58 @@ export default function BillingDetails() {
|
||||
fz="xs"
|
||||
className={classes.label}
|
||||
>
|
||||
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}
|
||||
Cost
|
||||
</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>
|
||||
</Group>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,24 +2,28 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
List,
|
||||
SegmentedControl,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Select,
|
||||
Container,
|
||||
Stack,
|
||||
Badge,
|
||||
Flex,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const [interval, setInterval] = useState("yearly");
|
||||
|
||||
if (!plans) {
|
||||
return null;
|
||||
}
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleCheckout = async (priceId: string) => {
|
||||
try {
|
||||
@ -32,84 +36,153 @@ 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 (
|
||||
<Group justify="center" p="xl">
|
||||
{plans.map((plan) => {
|
||||
const price =
|
||||
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||
<Container size="xl" py="xl">
|
||||
{/* Controls Section */}
|
||||
<Stack gap="xl" mb="md">
|
||||
{/* Team Size and Billing Controls */}
|
||||
<Group justify="center" align="center" gap="sm">
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
withBorder
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
p="xl"
|
||||
w={300}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
fullWidth
|
||||
data={[
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||
{plan.name}
|
||||
</Title>
|
||||
<Text ta="center" size="lg" fw={700}>
|
||||
{interval === "monthly" && (
|
||||
<>
|
||||
${price}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
<Card.Section mt="lg">
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Subscribe
|
||||
</Button>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section mt="md">
|
||||
<List
|
||||
spacing="xs"
|
||||
<Group justify="center" align="start">
|
||||
<Flex justify="center" gap="md" align="center">
|
||||
<Text size="md">Monthly</Text>
|
||||
<Switch
|
||||
defaultChecked={isAnnual}
|
||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon variant="light" size={24} radius="xl">
|
||||
<IconCheck size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, index) => (
|
||||
<List.Item key={index}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
/>
|
||||
<Text size="md">
|
||||
Annually
|
||||
<Badge component="span" variant="light" color="blue">
|
||||
15% OFF
|
||||
</Badge>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<Group justify="center" gap="lg" align="stretch">
|
||||
{plans.map((plan, index) => {
|
||||
const tieredPlan = plan;
|
||||
const planSelectedTier =
|
||||
tieredPlan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || tieredPlan.pricingTiers[0];
|
||||
|
||||
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>
|
||||
</Stack>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon size={20} radius="xl">
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, featureIndex) => (
|
||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,6 +25,11 @@ export interface IBilling {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
billingScheme: string | null;
|
||||
tieredUpTo: string | null;
|
||||
tieredFlatAmount: number | null;
|
||||
tieredUnitAmount: number | null;
|
||||
planName: string | null;
|
||||
}
|
||||
|
||||
export interface ICheckoutLink {
|
||||
@ -42,9 +47,18 @@ export interface IBillingPlan {
|
||||
monthlyId: string;
|
||||
yearlyId: string;
|
||||
currency: string;
|
||||
price: {
|
||||
price?: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
features: string[];
|
||||
billingScheme: string | null;
|
||||
pricingTiers: PricingTier[];
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
upTo: number;
|
||||
monthly?: number;
|
||||
yearly?: number;
|
||||
custom?: boolean;
|
||||
}
|
||||
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
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,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
@ -71,39 +72,43 @@ export function InviteSignUpForm() {
|
||||
{t("Join the workspace")}
|
||||
</Title>
|
||||
|
||||
<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")}
|
||||
/>
|
||||
<SsoLogin />
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
value={invitation.email}
|
||||
disabled
|
||||
variant="filled"
|
||||
mt="md"
|
||||
/>
|
||||
{!invitation.enforceSso && (
|
||||
<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")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
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>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
value={invitation.email}
|
||||
disabled
|
||||
variant="filled"
|
||||
mt="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
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>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().trim().min(3).max(50),
|
||||
workspaceName: z.string().trim().max(50).optional(),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z
|
||||
.string()
|
||||
@ -60,15 +60,17 @@ export function SetupWorkspaceForm() {
|
||||
{isCloud() && <SsoCloudSignup />}
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
{!isCloud() && (
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
|
||||
@ -10,7 +10,7 @@ export interface IRegister {
|
||||
}
|
||||
|
||||
export interface ISetupWorkspace {
|
||||
workspaceName: string;
|
||||
workspaceName?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
@ -36,5 +36,5 @@ export interface IVerifyUserToken {
|
||||
}
|
||||
|
||||
export interface ICollabToken {
|
||||
token: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
@ -12,6 +12,12 @@
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
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 {
|
||||
|
||||
@ -116,6 +116,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onCreate: (instance) => {
|
||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
@ -177,8 +183,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
<LinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||
setIsOpen={(value) => {
|
||||
setIsLinkSelectorOpen(value);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
|
||||
@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
name !== "Default" &&
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor.chain().focus().setColor(color || "").run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
|
||||
@ -15,13 +15,13 @@ import {
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@/features/editor/components/embed/providers.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@docmost/editor-ext";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
@ -32,7 +32,7 @@ const schema = z.object({
|
||||
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes } = props;
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { src, provider } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
@ -50,8 +50,16 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
});
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.id === "iframe") {
|
||||
updateAttributes({ src: data.url });
|
||||
return;
|
||||
}
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
} else {
|
||||
@ -81,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
</AspectRatio>
|
||||
</>
|
||||
) : (
|
||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
disabled={!editor.isEditable}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Card
|
||||
radius="md"
|
||||
@ -101,7 +115,7 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Embed {{provider}}", {
|
||||
provider: getEmbedProviderById(provider).name,
|
||||
provider: getEmbedProviderById(provider)?.name,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
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,7 +13,8 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { IAttachment } from "@/lib/types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import clsx from "clsx";
|
||||
@ -21,6 +22,8 @@ import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
@ -35,6 +38,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
adapter: localStorageLibraryAdapter,
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
type SearchAndReplaceAtomType = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
||||
isOpen: false,
|
||||
});
|
||||
@ -0,0 +1,312 @@
|
||||
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;
|
||||
@ -0,0 +1,10 @@
|
||||
.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,
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar,
|
||||
} from "@tabler/icons-react";
|
||||
IconCalendar, IconAppWindow,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@ -357,6 +357,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.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",
|
||||
description: "Embed Airtable",
|
||||
|
||||
@ -17,9 +17,9 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
} from '@tabler/icons-react';
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -50,6 +50,14 @@ export const TableMenu = React.memo(
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderRow = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const addColumnLeft = useCallback(() => {
|
||||
editor.chain().focus().addColumnBefore().run();
|
||||
}, [editor]);
|
||||
@ -180,6 +188,30 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</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")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
|
||||
@ -36,6 +36,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@ -58,6 +59,7 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
import elixir from "highlight.js/lib/languages/elixir";
|
||||
import erlang from "highlight.js/lib/languages/erlang";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
@ -72,11 +74,12 @@ import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import { countWords } from "alfaaz";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("abap", abap);
|
||||
lowlight.register("erlang", erlang);
|
||||
lowlight.register("elixir", elixir);
|
||||
lowlight.register("dockerfile", dockerfile);
|
||||
@ -212,7 +215,25 @@ export const mainExtensions = [
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CharacterCount
|
||||
CharacterCount.configure({
|
||||
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;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
@ -228,4 +249,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
color: randomElement(userColors),
|
||||
},
|
||||
}),
|
||||
];
|
||||
];
|
||||
|
||||
@ -42,7 +42,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@ -45,6 +44,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
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 { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
@ -52,6 +52,7 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
interface PageEditorProps {
|
||||
@ -71,7 +72,11 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||
const ydocRef = useRef<Y.Doc | null>(null);
|
||||
if (!ydocRef.current) {
|
||||
ydocRef.current = new Y.Doc();
|
||||
}
|
||||
const ydoc = ydocRef.current;
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@ -85,67 +90,103 @@ export default function PageEditor({
|
||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
provider.on("synced", () => {
|
||||
setLocalSynced(true);
|
||||
});
|
||||
const localProvider = providersRef.current?.local;
|
||||
const remoteProvider = providersRef.current?.remote;
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
// Track when collaborative provider is ready and synced
|
||||
const [collabReady, setCollabReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
||||
isLocalSynced &&
|
||||
isRemoteSynced
|
||||
) {
|
||||
setCollabReady(true);
|
||||
}
|
||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
||||
|
||||
const remoteProvider = useMemo(() => {
|
||||
const provider = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: false,
|
||||
preserveConnection: false,
|
||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
provider.on("synced", () => {
|
||||
setRemoteSynced(true);
|
||||
});
|
||||
|
||||
provider.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [ydoc, pageId, collabQuery?.token]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
remoteProvider.connect();
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
local.on("synced", () => setLocalSynced(true));
|
||||
const remote = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: true,
|
||||
preserveConnection: false,
|
||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
remote.on("synced", () => setRemoteSynced(true));
|
||||
remote.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
providersRef.current = { local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
setRemoteSynced(false);
|
||||
setLocalSynced(false);
|
||||
remoteProvider.destroy();
|
||||
localProvider.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [remoteProvider, localProvider]);
|
||||
}, [pageId]);
|
||||
|
||||
// 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(() => {
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@ -199,7 +240,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider?.status],
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||
@ -252,29 +293,6 @@ export default function PageEditor({
|
||||
}
|
||||
}, [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;
|
||||
|
||||
useEffect(() => {
|
||||
@ -290,10 +308,50 @@ export default function PageEditor({
|
||||
return () => clearTimeout(collabReadyTimeout);
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
|
||||
return isCollabReady ? (
|
||||
<div>
|
||||
useEffect(() => {
|
||||
// Only honor user default page edit mode preference and permissions
|
||||
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}>
|
||||
<EditorContent editor={editor} />
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
@ -308,21 +366,12 @@ export default function PageEditor({
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
) : (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
></EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -71,4 +71,12 @@
|
||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/client/src/features/editor/styles/find.css
Normal file
9
apps/client/src/features/editor/styles/find.css
Normal file
@ -0,0 +1,9 @@
|
||||
.search-result{
|
||||
background: #ffff65;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.search-result-current{
|
||||
background: #ffc266 !important;
|
||||
color: #212529;
|
||||
}
|
||||
@ -9,5 +9,5 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
@import "./find.css";
|
||||
@import "./mention.css";
|
||||
|
||||
|
||||
@ -10,8 +10,11 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
@ -21,6 +24,8 @@ import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
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 {
|
||||
pageId: string;
|
||||
@ -38,12 +43,16 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@ -103,7 +112,12 @@ export function TitleEditor({
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: { title: page.title, slugId: page.slugId },
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
@ -136,9 +150,30 @@ export function TitleEditor({
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
function handleTitleKeyDown(event) {
|
||||
useEffect(() => {
|
||||
// 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;
|
||||
|
||||
// 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 { $head } = titleEditor.state.selection;
|
||||
|
||||
@ -152,5 +187,16 @@ export function TitleEditor({
|
||||
}
|
||||
}
|
||||
|
||||
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||
return (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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,9 +1,10 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function getPageHistoryList(
|
||||
pageId: string,
|
||||
): Promise<IPageHistory[]> {
|
||||
): Promise<IPagination<IPageHistory>> {
|
||||
const req = await api.post("/pages/history", {
|
||||
pageId,
|
||||
});
|
||||
|
||||
@ -1,24 +1,30 @@
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
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;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.mantine-Breadcrumbs-breadcrumb {
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.truncatedText {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.breadcrumbDiv {
|
||||
overflow: hidden;
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +161,7 @@ export default function Breadcrumb() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
<div className={classes.breadcrumbDiv}>
|
||||
{breadcrumbNodes && (
|
||||
<Breadcrumbs className={classes.breadcrumbs}>
|
||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
IconList,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
@ -16,7 +17,12 @@ import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@ -32,7 +38,9 @@ import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} 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 { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||
@ -45,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const toggleAside = useToggleAside();
|
||||
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 (
|
||||
<>
|
||||
{yjsConnectionStatus === "disconnected" && (
|
||||
@ -59,6 +87,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||
|
||||
<ShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
|
||||
@ -1,15 +1,27 @@
|
||||
.header {
|
||||
height: 45px;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
height: 45px;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
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) {
|
||||
return (
|
||||
<div className={classes.header}>
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
||||
<Breadcrumb />
|
||||
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
||||
<PageHeaderMenu readOnly={readOnly} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@ -1,18 +1,38 @@
|
||||
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
FileButton,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandNotion,
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconFileTypeZip,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { importPage } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
importPage,
|
||||
importZip,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 {
|
||||
spaceId: string;
|
||||
@ -36,6 +56,7 @@ export default function PageImportModal({
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
keepMounted={true}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
@ -59,6 +80,133 @@ interface ImportFormatSelection {
|
||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
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[]) => {
|
||||
if (!selectedFiles) {
|
||||
@ -120,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
@ -148,7 +297,76 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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,5 +1,8 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@ -14,6 +17,7 @@ import {
|
||||
movePage,
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getAllSidebarPages,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@ -56,7 +60,9 @@ export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
mutationFn: (data) => createPage(data),
|
||||
onSuccess: (data) => {},
|
||||
onSuccess: (data) => {
|
||||
invalidateOnCreatePage(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to create page"), color: "red" });
|
||||
},
|
||||
@ -80,6 +86,8 @@ export function updatePageData(data: IPage) {
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
}
|
||||
|
||||
export function useUpdateTitlePageMutation() {
|
||||
@ -93,6 +101,8 @@ export function useUpdatePageMutation() {
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {
|
||||
updatePage(data);
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -101,8 +111,9 @@ export function useDeletePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, pageId) => {
|
||||
notifications.show({ message: t("Page deleted successfully") });
|
||||
invalidateOnDeletePage(pageId);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||
@ -113,15 +124,21 @@ export function useDeletePageMutation() {
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
mutationFn: (data) => movePage(data),
|
||||
onSuccess: () => {
|
||||
invalidateOnMovePage();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryFn: () => getSidebarPages(data),
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getPreviousPageParam: (firstPage) =>
|
||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@ -149,14 +166,16 @@ export function usePageBreadcrumbsQuery(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAncestorChildren(params: SidebarPagesParams) {
|
||||
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
||||
// not using a hook here, so we can call it inside a useEffect hook
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
return buildTree(response.items);
|
||||
|
||||
const allItems = response.pages.flatMap((page) => page.items);
|
||||
return buildTree(allItems);
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(
|
||||
@ -168,3 +187,157 @@ export function useRecentChangesQuery(
|
||||
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,9 +7,11 @@ import {
|
||||
IPage,
|
||||
IPageInput,
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
} from '@/features/page/types/page.types';
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
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> {
|
||||
const req = await api.post<IPage>("/pages/create", data);
|
||||
@ -52,6 +54,32 @@ export async function getSidebarPages(
|
||||
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(
|
||||
pageId: string,
|
||||
): Promise<Partial<IPage[]>> {
|
||||
@ -92,6 +120,25 @@ export async function importPage(file: File, spaceId: string) {
|
||||
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(
|
||||
file: File,
|
||||
pageId: string,
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
import { atom } from "jotai";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { appendNodeChildren } from "../utils";
|
||||
|
||||
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 { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
fetchAncestorChildren,
|
||||
fetchAllAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
@ -24,7 +24,10 @@ import {
|
||||
IconPointFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import {
|
||||
appendNodeChildrenAtom,
|
||||
treeDataAtom,
|
||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import clsx from "clsx";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
@ -32,6 +35,7 @@ import {
|
||||
appendNodeChildren,
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
mergeRootTrees,
|
||||
updateTreeNodeIcon,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@ -104,17 +108,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||
const treeData = buildTree(allItems);
|
||||
|
||||
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
|
||||
//Thoughts
|
||||
// don't reset if there is data in state
|
||||
// we only expect to call this once on initial load
|
||||
// even if we decide to refetch, it should only update
|
||||
// and append root pages instead of resetting the entire tree
|
||||
// which looses async loaded children too
|
||||
setData(treeData);
|
||||
setIsDataLoaded(true);
|
||||
setOpenTreeNodes({});
|
||||
}
|
||||
setData((prev) => {
|
||||
// fresh space; full reset
|
||||
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
|
||||
setIsDataLoaded(true);
|
||||
setOpenTreeNodes({});
|
||||
return treeData;
|
||||
}
|
||||
|
||||
// same space; append only missing roots
|
||||
return mergeRootTrees(prev, treeData);
|
||||
});
|
||||
}
|
||||
}, [pagesData, hasNextPage]);
|
||||
|
||||
@ -140,7 +144,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
if (ancestor.id === currentPage.id) {
|
||||
return;
|
||||
}
|
||||
const children = await fetchAncestorChildren({
|
||||
const children = await fetchAllAncestorChildren({
|
||||
pageId: ancestor.id,
|
||||
spaceId: ancestor.spaceId,
|
||||
});
|
||||
@ -237,6 +241,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
const { t } = useTranslation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
|
||||
const emit = useQueryEmit();
|
||||
const { spaceSlug } = useParams();
|
||||
const timerRef = useRef(null);
|
||||
@ -262,9 +267,10 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
|
||||
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||
if (!node.data.hasChildren) return;
|
||||
if (node.data.children && node.data.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
|
||||
// if (node.data.children && node.data.children.length > 0) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
const params: SidebarPagesParams = {
|
||||
@ -272,21 +278,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
spaceId: node.data.spaceId,
|
||||
};
|
||||
|
||||
const newChildren = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
const childrenTree = await fetchAllAncestorChildren(params);
|
||||
|
||||
appendChildren({
|
||||
parentId: node.data.id,
|
||||
children: childrenTree,
|
||||
});
|
||||
|
||||
const childrenTree = buildTree(newChildren.items);
|
||||
|
||||
const updatedTreeData = appendNodeChildren(
|
||||
treeData,
|
||||
node.data.id,
|
||||
childrenTree,
|
||||
);
|
||||
|
||||
setTreeData(updatedTreeData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch children:", error);
|
||||
}
|
||||
@ -304,17 +301,19 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
handleUpdateNodeIcon(node.id, emoji.native);
|
||||
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: emoji.native },
|
||||
updatePageMutation
|
||||
.mutateAsync({ pageId: node.id, icon: emoji.native })
|
||||
.then((data) => {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: emoji.native, parentPageId: data.parentPageId },
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleRemoveEmoji = () => {
|
||||
@ -576,6 +575,12 @@ interface PageArrowProps {
|
||||
}
|
||||
|
||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||
useEffect(() => {
|
||||
if (node.isOpen) {
|
||||
onExpandTree();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
size={20}
|
||||
|
||||
@ -93,7 +93,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
return data;
|
||||
};
|
||||
|
||||
const onMove: MoveHandler<T> = (args: {
|
||||
const onMove: MoveHandler<T> = async (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<T>[];
|
||||
parentId: string | null;
|
||||
@ -176,7 +176,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
};
|
||||
|
||||
try {
|
||||
movePageMutation.mutateAsync(payload);
|
||||
await movePageMutation.mutateAsync(payload);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
@ -206,6 +206,23 @@ 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[] }) => {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
@ -218,8 +235,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
|
||||
// navigate only if the current url is same as the deleted page
|
||||
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
}
|
||||
|
||||
|
||||
@ -121,7 +121,6 @@ export const deleteTreeNode = (
|
||||
.filter((node) => node !== null);
|
||||
};
|
||||
|
||||
|
||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
const nodeMap = {};
|
||||
let result: SpaceTreeNode[] = [];
|
||||
@ -164,16 +163,55 @@ export function appendNodeChildren(
|
||||
nodeId: string,
|
||||
children: SpaceTreeNode[],
|
||||
) {
|
||||
return treeItems.map((nodeItem) => {
|
||||
if (nodeItem.id === nodeId) {
|
||||
return { ...nodeItem, children };
|
||||
}
|
||||
if (nodeItem.children) {
|
||||
// Preserve deeper children if they exist and remove node if deleted
|
||||
return treeItems.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
const newIds = new Set(children.map((c) => c.id));
|
||||
|
||||
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 {
|
||||
...nodeItem,
|
||||
children: appendNodeChildren(nodeItem.children, nodeId, children),
|
||||
...node,
|
||||
children: merged,
|
||||
};
|
||||
}
|
||||
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,6 +65,7 @@ export interface IPageInput {
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
position: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export interface IExportPageParams {
|
||||
|
||||
16
apps/client/src/features/share/components/share-branding.tsx
Normal file
16
apps/client/src/features/share/components/share-branding.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
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,6 +36,7 @@ import {
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||
|
||||
const MemoizedSharedTree = React.memo(SharedTree);
|
||||
|
||||
@ -163,16 +164,7 @@ export default function ShareShell({
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
</Button>
|
||||
</Affix>
|
||||
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside
|
||||
|
||||
@ -41,6 +41,7 @@ export interface ISharedPage extends IShare {
|
||||
level: number;
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
hasLicenseKey: boolean;
|
||||
}
|
||||
|
||||
export interface IShareForPage extends IShare {
|
||||
@ -70,4 +71,5 @@ export interface IShareInfoInput {
|
||||
export interface ISharedPageTree {
|
||||
share: IShare;
|
||||
pageTree: Partial<IPage[]>;
|
||||
hasLicenseKey: boolean;
|
||||
}
|
||||
|
||||
@ -42,14 +42,15 @@ function LanguageSwitcher() {
|
||||
label={t("Select language")}
|
||||
data={[
|
||||
{ value: "en-US", label: "English (US)" },
|
||||
{ value: "de-DE", label: "Deutsch (German)" },
|
||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||
{ value: "fr-FR", label: "Français (French)" },
|
||||
{ value: "es-ES", label: "Español (Spanish)" },
|
||||
{ value: "de-DE", label: "Deutsch (German)" },
|
||||
{ value: "fr-FR", label: "Français (French)" },
|
||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||
{ value: "it-IT", label: "Italiano (Italian)" },
|
||||
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
||||
{ value: "ko-KR", label: "한국어 (Korean)" },
|
||||
{ value: "uk-UA", label: "Українська (Ukrainian)" },
|
||||
{ value: "ru-RU", label: "Русский (Russian)" },
|
||||
{ value: "zh-CN", label: "中文 (简体)" },
|
||||
]}
|
||||
|
||||
@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(40),
|
||||
name: z.string().min(1).max(40),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
65
apps/client/src/features/user/components/page-state-pref.tsx
Normal file
65
apps/client/src/features/user/components/page-state-pref.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
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,6 +19,7 @@ export interface IUser {
|
||||
deactivatedAt: Date;
|
||||
deletedAt: Date;
|
||||
fullPageWidth: boolean; // used for update
|
||||
pageEditMode: string; // used for update
|
||||
}
|
||||
|
||||
export interface ICurrentUser {
|
||||
@ -29,5 +30,11 @@ export interface ICurrentUser {
|
||||
export interface IUserSettings {
|
||||
preferences: {
|
||||
fullPageWidth: boolean;
|
||||
pageEditMode: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export enum PageEditMode {
|
||||
Read = "read",
|
||||
Edit = "edit",
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
export type InvalidateEvent = {
|
||||
operation: "invalidate";
|
||||
@ -17,7 +18,7 @@ export type UpdateEvent = {
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id: string;
|
||||
payload: Partial<any>;
|
||||
payload: Partial<IPage>;
|
||||
};
|
||||
|
||||
export type DeleteEvent = {
|
||||
@ -25,7 +26,7 @@ export type DeleteEvent = {
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id: string;
|
||||
payload?: Partial<any>;
|
||||
payload?: Partial<IPage>;
|
||||
};
|
||||
|
||||
export type AddTreeNodeEvent = {
|
||||
@ -46,15 +47,28 @@ export type MoveTreeNodeEvent = {
|
||||
parentId: string;
|
||||
index: number;
|
||||
position: string;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteTreeNodeEvent = {
|
||||
operation: "deleteTreeNode";
|
||||
spaceId: string;
|
||||
payload: {
|
||||
node: SpaceTreeNode
|
||||
}
|
||||
node: SpaceTreeNode;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||
export type RefetchRootTreeNodeEvent = {
|
||||
operation: "refetchRootTreeNodeEvent";
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type WebSocketEvent =
|
||||
| InvalidateEvent
|
||||
| InvalidateCommentsEvent
|
||||
| UpdateEvent
|
||||
| DeleteEvent
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent;
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import React from "react";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { InfiniteData, useQueryClient } from "@tanstack/react-query";
|
||||
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 { queryClient } from "@/main.tsx";
|
||||
|
||||
export const useQuerySubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@ -27,6 +36,15 @@ export const useQuerySubscription = () => {
|
||||
queryKey: RQ_KEY(data.pageId),
|
||||
});
|
||||
break;
|
||||
case "addTreeNode":
|
||||
invalidateOnCreatePage(data.payload.data);
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
invalidateOnMovePage();
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
invalidateOnDeletePage(data.payload.node.id);
|
||||
break;
|
||||
case "updateOne":
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
@ -37,13 +55,23 @@ export const useQuerySubscription = () => {
|
||||
}
|
||||
|
||||
// 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.getQueryData([...data.entity, queryKeyId]),
|
||||
...data.payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (entity === "pages") {
|
||||
invalidateOnUpdatePage(
|
||||
data.spaceId,
|
||||
data.payload.parentPageId,
|
||||
data.id,
|
||||
data.payload.title,
|
||||
data.payload.icon,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: [data.entity, data.id] },
|
||||
@ -57,6 +85,17 @@ export const useQuerySubscription = () => {
|
||||
);
|
||||
*/
|
||||
break;
|
||||
case "refetchRootTreeNodeEvent": {
|
||||
const spaceId = data.spaceId;
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["root-sidebar-pages", spaceId],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
|
||||
@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(4),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() {
|
||||
|
||||
export function useGetInvitationQuery(
|
||||
invitationId: string,
|
||||
): UseQueryResult<any, Error> {
|
||||
): UseQueryResult<IInvitation, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["invitations", invitationId],
|
||||
queryFn: () => getInvitationById({ invitationId }),
|
||||
|
||||
@ -12,6 +12,7 @@ export interface IWorkspace {
|
||||
settings: any;
|
||||
status: string;
|
||||
enforceSso: boolean;
|
||||
stripeCustomerId: string;
|
||||
billingEmail: string;
|
||||
trialEndAt: Date;
|
||||
createdAt: Date;
|
||||
@ -35,6 +36,7 @@ export interface IInvitation {
|
||||
workspaceId: string;
|
||||
invitedById: string;
|
||||
createdAt: Date;
|
||||
enforceSso: boolean;
|
||||
}
|
||||
|
||||
export interface IInvitationLink {
|
||||
|
||||
@ -70,6 +70,11 @@ export function getFileUploadSizeLimit() {
|
||||
return bytes(limit);
|
||||
}
|
||||
|
||||
export function getFileImportSizeLimit() {
|
||||
const limit = getConfigValue("FILE_IMPORT_SIZE_LIMIT", "200mb");
|
||||
return bytes(limit);
|
||||
}
|
||||
|
||||
export function getDrawioUrl() {
|
||||
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
||||
}
|
||||
@ -78,6 +83,18 @@ export function getBillingTrialDays() {
|
||||
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 {
|
||||
const rawValue = import.meta.env.DEV
|
||||
? process?.env?.[key]
|
||||
|
||||
@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { mantineCssResolver, theme } from '@/theme';
|
||||
import { mantineCssResolver, theme } from "@/theme";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
@ -11,6 +11,14 @@ import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
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({
|
||||
defaultOptions: {
|
||||
@ -23,9 +31,17 @@ 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(
|
||||
document.getElementById("root") as HTMLElement
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
root.render(
|
||||
@ -35,10 +51,12 @@ root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} />
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@ -12,6 +12,11 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
@ -49,14 +54,14 @@ export default function Page() {
|
||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||
</Helmet>
|
||||
|
||||
<PageHeader
|
||||
<MemoizedPageHeader
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullEditor
|
||||
<MemoizedFullEditor
|
||||
key={page.id}
|
||||
pageId={page.id}
|
||||
title={page.title}
|
||||
@ -68,7 +73,7 @@ export default function Page() {
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
/>
|
||||
<HistoryModal pageId={page.id} />
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import AccountLanguage from "@/features/user/components/account-language.tsx";
|
||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Divider } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@ -28,6 +29,10 @@ export default function AccountPreferences() {
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageWidthPref />
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageEditPref />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,8 +7,9 @@ import React, { useEffect } from "react";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
||||
|
||||
export default function SingleSharedPage() {
|
||||
export default function SharedPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
@ -53,6 +54,8 @@ export default function SingleSharedPage() {
|
||||
content={data.page.content}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{data && !shareId && !data.hasLicenseKey && <ShareBranding />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,11 +8,14 @@ export default defineConfig(({ mode }) => {
|
||||
const {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
FILE_IMPORT_SIZE_LIMIT,
|
||||
DRAWIO_URL,
|
||||
CLOUD,
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@ -20,11 +23,14 @@ export default defineConfig(({ mode }) => {
|
||||
"process.env": {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
FILE_IMPORT_SIZE_LIMIT,
|
||||
DRAWIO_URL,
|
||||
CLOUD,
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.20.4",
|
||||
"version": "0.21.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@ -31,56 +31,60 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.701.0",
|
||||
"@aws-sdk/lib-storage": "3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.20",
|
||||
"@nestjs/common": "^11.1.3",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.20",
|
||||
"@nestjs/event-emitter": "^3.0.0",
|
||||
"@nestjs/core": "^11.1.3",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.0.20",
|
||||
"@nestjs/platform-socket.io": "^11.0.20",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/platform-fastify": "^11.1.3",
|
||||
"@nestjs/platform-socket.io": "^11.1.3",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.20",
|
||||
"@nestjs/websockets": "^11.1.3",
|
||||
"@node-saml/passport-saml": "^5.0.1",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.41.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"cache-manager": "^6.4.3",
|
||||
"cheerio": "^1.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "^15.11.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.27.5",
|
||||
"kysely": "^0.28.2",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.1.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openid-client": "^5.7.1",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.13.3",
|
||||
"pg": "^8.16.0",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"tmp-promise": "^3.0.3",
|
||||
"ws": "^8.18.2",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
@ -99,6 +103,7 @@
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.15.0",
|
||||
|
||||
@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension {
|
||||
);
|
||||
this.contributors.delete(documentName);
|
||||
} catch (err) {
|
||||
this.logger.debug('Contributors error:' + err?.['message']);
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
|
||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||
|
||||
@ -15,6 +16,12 @@ export async function comparePasswordHash(
|
||||
return bcrypt.compare(plainPassword, passwordHash);
|
||||
}
|
||||
|
||||
export function generateRandomSuffixNumbers(length: number) {
|
||||
return Math.random()
|
||||
.toFixed(length)
|
||||
.substring(2, 2 + length);
|
||||
}
|
||||
|
||||
export type RedisConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
@ -62,3 +69,8 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
}
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@ -23,7 +21,6 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { PasswordResetDto } from './dto/password-reset.dto';
|
||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { addDays } from 'date-fns';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
|
||||
@Controller('auth')
|
||||
@ -125,7 +122,7 @@ export class AuthController {
|
||||
res.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
expires: addDays(new Date(), 30),
|
||||
expires: this.environmentService.getCookieExpiresIn(),
|
||||
secure: this.environmentService.isHttps(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,3 +6,16 @@ export function validateSsoEnforcement(workspace: Workspace) {
|
||||
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,6 +1,12 @@
|
||||
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateAdminUserDto extends CreateUserDto {
|
||||
@IsNotEmpty()
|
||||
@ -9,10 +15,17 @@ export class CreateAdminUserDto extends CreateUserDto {
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
workspaceName: string;
|
||||
|
||||
@IsOptional()
|
||||
@MinLength(4)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
@ -92,7 +92,8 @@ export class SignupService {
|
||||
|
||||
// create workspace with full setup
|
||||
const workspaceData: CreateWorkspaceDto = {
|
||||
name: createAdminUserDto.workspaceName,
|
||||
name: createAdminUserDto.workspaceName || 'My workspace',
|
||||
hostname: createAdminUserDto.hostname,
|
||||
};
|
||||
|
||||
workspace = await this.workspaceService.create(
|
||||
|
||||
@ -16,6 +16,7 @@ import { GroupModule } from './group/group.module';
|
||||
import { CaslModule } from './casl/casl.module';
|
||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -30,6 +31,7 @@ import { ShareModule } from './share/share.module';
|
||||
GroupModule,
|
||||
CaslModule,
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
|
||||
276
apps/server/src/core/notification/INTEGRATION_GUIDE.md
Normal file
276
apps/server/src/core/notification/INTEGRATION_GUIDE.md
Normal file
@ -0,0 +1,276 @@
|
||||
# Notification System Integration Guide
|
||||
|
||||
This guide explains how to integrate the notification system into existing services.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Import NotificationService
|
||||
|
||||
```typescript
|
||||
import { NotificationService } from '@/core/notification/services/notification.service';
|
||||
import { NotificationType } from '@/core/notification/types/notification.types';
|
||||
```
|
||||
|
||||
### 2. Inject the Service
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
// ... other dependencies
|
||||
) {}
|
||||
```
|
||||
|
||||
### 3. Create Notifications
|
||||
|
||||
```typescript
|
||||
// Example: Notify user when mentioned in a comment
|
||||
await this.notificationService.createNotification({
|
||||
workspaceId: workspace.id,
|
||||
recipientId: mentionedUserId,
|
||||
actorId: currentUser.id,
|
||||
type: NotificationType.MENTION_IN_COMMENT,
|
||||
entityType: 'comment',
|
||||
entityId: comment.id,
|
||||
context: {
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
commentText: comment.content.substring(0, 100),
|
||||
actorName: currentUser.name,
|
||||
threadRootId: comment.parentCommentId || comment.id,
|
||||
},
|
||||
priority: NotificationPriority.HIGH,
|
||||
groupKey: `comment:${comment.id}:mentions`,
|
||||
deduplicationKey: `mention:${mentionedUserId}:comment:${comment.id}`,
|
||||
});
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### CommentService Integration
|
||||
|
||||
```typescript
|
||||
// In comment.service.ts
|
||||
import { NotificationService } from '@/core/notification/services/notification.service';
|
||||
import { NotificationType, NotificationPriority } from '@/core/notification/types/notification.types';
|
||||
|
||||
export class CommentService {
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
// ... other dependencies
|
||||
) {}
|
||||
|
||||
async createComment(dto: CreateCommentDto, user: User): Promise<Comment> {
|
||||
const comment = await this.commentRepo.create(dto);
|
||||
|
||||
// Notify page owner about new comment
|
||||
if (page.creatorId !== user.id) {
|
||||
await this.notificationService.createNotification({
|
||||
workspaceId: workspace.id,
|
||||
recipientId: page.creatorId,
|
||||
actorId: user.id,
|
||||
type: NotificationType.COMMENT_ON_PAGE,
|
||||
entityType: 'comment',
|
||||
entityId: comment.id,
|
||||
context: {
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
commentText: comment.content.substring(0, 100),
|
||||
actorName: user.name,
|
||||
},
|
||||
groupKey: `page:${page.id}:comments`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for mentions and notify mentioned users
|
||||
const mentionedUserIds = this.extractMentions(comment.content);
|
||||
for (const mentionedUserId of mentionedUserIds) {
|
||||
await this.notificationService.createNotification({
|
||||
workspaceId: workspace.id,
|
||||
recipientId: mentionedUserId,
|
||||
actorId: user.id,
|
||||
type: NotificationType.MENTION_IN_COMMENT,
|
||||
entityType: 'comment',
|
||||
entityId: comment.id,
|
||||
context: {
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
commentText: comment.content.substring(0, 100),
|
||||
actorName: user.name,
|
||||
threadRootId: comment.parentCommentId || comment.id,
|
||||
},
|
||||
priority: NotificationPriority.HIGH,
|
||||
deduplicationKey: `mention:${mentionedUserId}:comment:${comment.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async resolveComment(commentId: string, user: User): Promise<void> {
|
||||
const comment = await this.commentRepo.findById(commentId);
|
||||
|
||||
// Notify comment creator that their comment was resolved
|
||||
if (comment.creatorId !== user.id) {
|
||||
await this.notificationService.createNotification({
|
||||
workspaceId: workspace.id,
|
||||
recipientId: comment.creatorId,
|
||||
actorId: user.id,
|
||||
type: NotificationType.COMMENT_RESOLVED,
|
||||
entityType: 'comment',
|
||||
entityId: comment.id,
|
||||
context: {
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
resolverName: user.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PageService Integration
|
||||
|
||||
```typescript
|
||||
// In page.service.ts
|
||||
async exportPage(pageId: string, format: string, user: User): Promise<void> {
|
||||
// Start export process...
|
||||
|
||||
// When export is complete
|
||||
await this.notificationService.createNotification({
|
||||
workspaceId: workspace.id,
|
||||
recipientId: user.id,
|
||||
actorId: user.id, // System notification
|
||||
type: NotificationType.EXPORT_COMPLETED,
|
||||
entityType: 'page',
|
||||
entityId: pageId,
|
||||
context: {
|
||||
pageTitle: page.title,
|
||||
exportFormat: format,
|
||||
downloadUrl: exportUrl,
|
||||
expiresAt: expiryDate.toISOString(),
|
||||
},
|
||||
priority: NotificationPriority.LOW,
|
||||
});
|
||||
}
|
||||
|
||||
async updatePage(pageId: string, content: any, user: User): Promise<void> {
|
||||
// Check for mentions in the content
|
||||
const mentionedUserIds = this.extractMentionsFromContent(content);
|
||||
|
||||
for (const mentionedUserId of mentionedUserIds) {
|
||||
await this.notificationService.createNotification({
|
||||
workspaceId: workspace.id,
|
||||
recipientId: mentionedUserId,
|
||||
actorId: user.id,
|
||||
type: NotificationType.MENTION_IN_PAGE,
|
||||
entityType: 'page',
|
||||
entityId: pageId,
|
||||
context: {
|
||||
pageTitle: page.title,
|
||||
actorName: user.name,
|
||||
mentionContext: this.extractMentionContext(content, mentionedUserId),
|
||||
},
|
||||
priority: NotificationPriority.HIGH,
|
||||
deduplicationKey: `mention:${mentionedUserId}:page:${pageId}:${Date.now()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WsGateway Integration for Real-time Notifications
|
||||
|
||||
The notification system automatically sends real-time updates through WebSocket. The WsGateway is already injected into NotificationDeliveryService.
|
||||
|
||||
```typescript
|
||||
// In ws.gateway.ts - Already implemented in NotificationDeliveryService
|
||||
async sendNotificationToUser(userId: string, notification: any): Promise<void> {
|
||||
const userSockets = await this.getUserSockets(userId);
|
||||
|
||||
for (const socketId of userSockets) {
|
||||
this.server.to(socketId).emit('notification:new', {
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
entityType: notification.entityType,
|
||||
entityId: notification.entityId,
|
||||
context: notification.context,
|
||||
createdAt: notification.createdAt,
|
||||
readAt: notification.readAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notification Types
|
||||
|
||||
Available notification types:
|
||||
- `MENTION_IN_PAGE` - User mentioned in a page
|
||||
- `MENTION_IN_COMMENT` - User mentioned in a comment
|
||||
- `COMMENT_ON_PAGE` - New comment on user's page
|
||||
- `COMMENT_IN_THREAD` - Reply to user's comment
|
||||
- `COMMENT_RESOLVED` - User's comment was resolved
|
||||
- `EXPORT_COMPLETED` - Export job finished
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Deduplication Keys**: Prevent duplicate notifications for the same event
|
||||
```typescript
|
||||
deduplicationKey: `mention:${userId}:comment:${commentId}`
|
||||
```
|
||||
|
||||
2. **Set Appropriate Priority**:
|
||||
- HIGH: Mentions, direct replies
|
||||
- NORMAL: Comments on owned content
|
||||
- LOW: System notifications, exports
|
||||
|
||||
3. **Group Related Notifications**: Use groupKey for notifications that should be batched
|
||||
```typescript
|
||||
groupKey: `page:${pageId}:comments`
|
||||
```
|
||||
|
||||
4. **Include Relevant Context**: Provide enough information for email templates
|
||||
```typescript
|
||||
context: {
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
actorName: user.name,
|
||||
// ... other relevant data
|
||||
}
|
||||
```
|
||||
|
||||
5. **Check User Preferences**: The notification service automatically checks user preferences, but you can pre-check if needed:
|
||||
```typescript
|
||||
const preferences = await notificationPreferenceService.getUserPreferences(userId, workspaceId);
|
||||
if (preferences.emailEnabled) {
|
||||
// Create notification
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Notifications
|
||||
|
||||
Use the test endpoint to send test notifications:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/notifications/test \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "MENTION_IN_PAGE",
|
||||
"recipientId": "USER_ID"
|
||||
}'
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are located in `/core/notification/templates/`. To add a new template:
|
||||
|
||||
1. Create a new React component in the templates directory
|
||||
2. Update the email sending logic in NotificationDeliveryService
|
||||
3. Test the template using the React Email preview server
|
||||
|
||||
## Monitoring
|
||||
|
||||
Monitor notification delivery through logs:
|
||||
- Check for `NotificationService` logs for creation events
|
||||
- Check for `NotificationDeliveryService` logs for delivery status
|
||||
- Check for `NotificationBatchProcessor` logs for batch processing
|
||||
@ -0,0 +1,122 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { NotificationPreferenceService } from '../services/notification-preference.service';
|
||||
import { GetNotificationsDto } from '../dto/get-notifications.dto';
|
||||
import { UpdateNotificationPreferencesDto } from '../dto/update-preference.dto';
|
||||
import { NotificationType } from '../types/notification.types';
|
||||
|
||||
@Controller('notifications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationController {
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly preferenceService: NotificationPreferenceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getNotifications(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Query() query: GetNotificationsDto,
|
||||
) {
|
||||
const { grouped = true, status, limit = 20, offset = 0 } = query;
|
||||
|
||||
if (grouped) {
|
||||
return await this.notificationService.getGroupedNotifications(
|
||||
user.id,
|
||||
workspace.id,
|
||||
{ status, limit, offset },
|
||||
);
|
||||
}
|
||||
|
||||
return await this.notificationService.getNotifications(
|
||||
user.id,
|
||||
workspace.id,
|
||||
{ status, limit, offset },
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
async getUnreadCount(@AuthUser() user: User) {
|
||||
const count = await this.notificationService.getUnreadCount(user.id);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Post(':id/read')
|
||||
async markAsRead(
|
||||
@AuthUser() user: User,
|
||||
@Param('id') notificationId: string,
|
||||
) {
|
||||
await this.notificationService.markAsRead(notificationId, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('mark-all-read')
|
||||
async markAllAsRead(@AuthUser() user: User) {
|
||||
await this.notificationService.markAllAsRead(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('preferences')
|
||||
async getPreferences(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return await this.preferenceService.getUserPreferences(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('preferences')
|
||||
async updatePreferences(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Body() dto: UpdateNotificationPreferencesDto,
|
||||
) {
|
||||
return await this.preferenceService.updateUserPreferences(
|
||||
user.id,
|
||||
workspace.id,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('preferences/stats')
|
||||
async getNotificationStats(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return await this.preferenceService.getNotificationStats(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
async sendTestNotification(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Body() dto: { type: NotificationType },
|
||||
) {
|
||||
await this.notificationService.createTestNotification(
|
||||
user.id,
|
||||
workspace.id,
|
||||
dto.type,
|
||||
);
|
||||
|
||||
return { success: true, message: 'Test notification sent' };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
} from '../types/notification.types';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
workspaceId: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
recipientId: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
actorId?: string;
|
||||
|
||||
@IsEnum(NotificationType)
|
||||
@IsNotEmpty()
|
||||
type: NotificationType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
entityType: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
entityId: string;
|
||||
|
||||
@IsObject()
|
||||
@IsNotEmpty()
|
||||
context: Record<string, any>;
|
||||
|
||||
@IsEnum(NotificationPriority)
|
||||
@IsOptional()
|
||||
priority?: NotificationPriority;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupKey?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deduplicationKey?: string;
|
||||
|
||||
// For scheduling notifications (quiet hours, etc.)
|
||||
@IsOptional()
|
||||
scheduledFor?: Date;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { NotificationStatus } from '../types/notification.types';
|
||||
|
||||
export class GetNotificationsDto {
|
||||
@IsEnum(NotificationStatus)
|
||||
@IsOptional()
|
||||
status?: NotificationStatus;
|
||||
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsOptional()
|
||||
grouped?: boolean = true;
|
||||
|
||||
@IsNumber()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit?: number = 20;
|
||||
|
||||
@IsNumber()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
offset?: number = 0;
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
Matches,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
EmailFrequency,
|
||||
NotificationTypeSettings,
|
||||
} from '../types/notification.types';
|
||||
|
||||
export class UpdateNotificationPreferencesDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
emailEnabled?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
inAppEnabled?: boolean;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
notificationSettings?: Record<string, NotificationTypeSettings>;
|
||||
|
||||
@IsNumber()
|
||||
@Min(5)
|
||||
@Max(60)
|
||||
@IsOptional()
|
||||
batchWindowMinutes?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
maxBatchSize?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
batchTypes?: string[];
|
||||
|
||||
@IsEnum(EmailFrequency)
|
||||
@IsOptional()
|
||||
emailFrequency?: EmailFrequency;
|
||||
|
||||
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
@IsOptional()
|
||||
digestTime?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
quietHoursEnabled?: boolean;
|
||||
|
||||
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
@IsOptional()
|
||||
quietHoursStart?: string;
|
||||
|
||||
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
@IsOptional()
|
||||
quietHoursEnd?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
timezone?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
weekendNotifications?: boolean;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { Notification } from '@docmost/db/types/entity.types';
|
||||
|
||||
export class NotificationCreatedEvent {
|
||||
constructor(
|
||||
public readonly notification: Notification,
|
||||
public readonly workspaceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class NotificationReadEvent {
|
||||
constructor(
|
||||
public readonly notificationId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class NotificationAllReadEvent {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly notificationIds: string[],
|
||||
) {}
|
||||
}
|
||||
|
||||
export class NotificationBatchScheduledEvent {
|
||||
constructor(
|
||||
public readonly batchId: string,
|
||||
public readonly scheduledFor: Date,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class NotificationAggregatedEvent {
|
||||
constructor(
|
||||
public readonly aggregationId: string,
|
||||
public readonly notificationIds: string[],
|
||||
) {}
|
||||
}
|
||||
|
||||
// Event names as constants
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
CREATED: 'notification.created',
|
||||
READ: 'notification.read',
|
||||
ALL_READ: 'notification.allRead',
|
||||
BATCH_SCHEDULED: 'notification.batchScheduled',
|
||||
AGGREGATED: 'notification.aggregated',
|
||||
} as const;
|
||||
32
apps/server/src/core/notification/notification.module.ts
Normal file
32
apps/server/src/core/notification/notification.module.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { NotificationService } from './services/notification.service';
|
||||
import { NotificationPreferenceService } from './services/notification-preference.service';
|
||||
import { NotificationDeduplicationService } from './services/notification-deduplication.service';
|
||||
import { NotificationDeliveryService } from './services/notification-delivery.service';
|
||||
import { NotificationBatchingService } from './services/notification-batching.service';
|
||||
import { NotificationAggregationService } from './services/notification-aggregation.service';
|
||||
import { NotificationController } from './controllers/notification.controller';
|
||||
import { NotificationBatchProcessor } from './queues/notification-batch.processor';
|
||||
import { WsModule } from '../../ws/ws.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'notification-batch',
|
||||
}),
|
||||
WsModule,
|
||||
],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
NotificationService,
|
||||
NotificationPreferenceService,
|
||||
NotificationDeduplicationService,
|
||||
NotificationDeliveryService,
|
||||
NotificationBatchingService,
|
||||
NotificationAggregationService,
|
||||
NotificationBatchProcessor,
|
||||
],
|
||||
exports: [NotificationService, NotificationPreferenceService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
@ -0,0 +1,70 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { WorkerHost } from '@nestjs/bullmq';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { NotificationBatchingService } from '../services/notification-batching.service';
|
||||
|
||||
@Processor('notification-batch')
|
||||
export class NotificationBatchProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(NotificationBatchProcessor.name);
|
||||
|
||||
constructor(private readonly batchingService: NotificationBatchingService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<any, any, string>) {
|
||||
if (job.name === 'process-batch') {
|
||||
return this.processBatch(job);
|
||||
} else if (job.name === 'check-pending-batches') {
|
||||
return this.checkPendingBatches(job);
|
||||
}
|
||||
}
|
||||
|
||||
async processBatch(job: Job<{ batchId: string }>) {
|
||||
this.logger.debug(`Processing notification batch: ${job.data.batchId}`);
|
||||
|
||||
try {
|
||||
await this.batchingService.processBatch(job.data.batchId);
|
||||
return { success: true, batchId: job.data.batchId };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process batch ${job.data.batchId}:`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkPendingBatches(job: Job) {
|
||||
this.logger.debug('Checking for pending notification batches');
|
||||
|
||||
try {
|
||||
const pendingBatches = await this.batchingService.getPendingBatches();
|
||||
|
||||
for (const batch of pendingBatches) {
|
||||
// Calculate delay
|
||||
const delay = Math.max(0, batch.scheduled_for.getTime() - Date.now());
|
||||
|
||||
// Add to queue with appropriate delay
|
||||
await this.queue.add('process-batch', { batchId: batch.id }, { delay });
|
||||
|
||||
this.logger.debug(
|
||||
`Scheduled batch ${batch.id} for processing in ${delay}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
return { processedCount: pendingBatches.length };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Failed to check pending batches:',
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Reference to the queue (injected by Bull)
|
||||
private get queue() {
|
||||
return (this as any).queue;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotificationAggregationRepo } from '@docmost/db/repos/notification/notification-aggregation.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { Notification, NotificationAggregation } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
NotificationType,
|
||||
AggregationType,
|
||||
AggregatedNotificationMessage,
|
||||
} from '../types/notification.types';
|
||||
|
||||
interface AggregationRule {
|
||||
types: NotificationType[];
|
||||
timeWindow: number;
|
||||
minCount: number;
|
||||
aggregationType: 'actor_based' | 'time_based' | 'count_based';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationAggregationService {
|
||||
private readonly logger = new Logger(NotificationAggregationService.name);
|
||||
|
||||
private readonly aggregationRules: Map<NotificationType, AggregationRule> =
|
||||
new Map([
|
||||
[
|
||||
NotificationType.COMMENT_ON_PAGE,
|
||||
{
|
||||
types: [NotificationType.COMMENT_ON_PAGE],
|
||||
timeWindow: 3600000, // 1 hour
|
||||
minCount: 2,
|
||||
aggregationType: 'actor_based',
|
||||
},
|
||||
],
|
||||
[
|
||||
NotificationType.MENTION_IN_COMMENT,
|
||||
{
|
||||
types: [
|
||||
NotificationType.MENTION_IN_COMMENT,
|
||||
NotificationType.MENTION_IN_PAGE,
|
||||
],
|
||||
timeWindow: 1800000, // 30 minutes
|
||||
minCount: 3,
|
||||
aggregationType: 'count_based',
|
||||
},
|
||||
],
|
||||
[
|
||||
NotificationType.COMMENT_IN_THREAD,
|
||||
{
|
||||
types: [NotificationType.COMMENT_IN_THREAD],
|
||||
timeWindow: 3600000, // 1 hour
|
||||
minCount: 2,
|
||||
aggregationType: 'actor_based',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private readonly aggregationRepo: NotificationAggregationRepo,
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
) {}
|
||||
|
||||
async aggregateNotifications(
|
||||
recipientId: string,
|
||||
type: NotificationType,
|
||||
entityId: string,
|
||||
timeWindow: number = 3600000, // 1 hour default
|
||||
): Promise<NotificationAggregation | null> {
|
||||
const aggregationKey = this.generateAggregationKey(
|
||||
recipientId,
|
||||
type,
|
||||
entityId,
|
||||
);
|
||||
|
||||
// Check if there's an existing aggregation within time window
|
||||
const existing = await this.aggregationRepo.findByKey(aggregationKey);
|
||||
|
||||
if (existing && this.isWithinTimeWindow(existing.updatedAt, timeWindow)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Find recent notifications to aggregate
|
||||
const recentNotifications = await this.notificationRepo.findRecent({
|
||||
recipientId,
|
||||
type,
|
||||
entityId,
|
||||
since: new Date(Date.now() - timeWindow),
|
||||
});
|
||||
|
||||
const rule = this.aggregationRules.get(type);
|
||||
if (!rule || recentNotifications.length < rule.minCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new aggregation
|
||||
return await this.createAggregation(
|
||||
aggregationKey,
|
||||
recentNotifications,
|
||||
type,
|
||||
);
|
||||
}
|
||||
|
||||
async updateAggregation(
|
||||
aggregation: NotificationAggregation,
|
||||
notification: Notification,
|
||||
): Promise<void> {
|
||||
await this.aggregationRepo.addNotificationToAggregation(
|
||||
aggregation.aggregationKey,
|
||||
notification.id,
|
||||
notification.actorId || undefined,
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Updated aggregation ${aggregation.id} with notification ${notification.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async createAggregation(
|
||||
key: string,
|
||||
notifications: Notification[],
|
||||
type: NotificationType,
|
||||
): Promise<NotificationAggregation> {
|
||||
const actors = [
|
||||
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
|
||||
];
|
||||
const notificationIds = notifications.map((n) => n.id);
|
||||
|
||||
const summaryData = {
|
||||
totalCount: notifications.length,
|
||||
actorCount: actors.length,
|
||||
firstActorId: actors[0],
|
||||
recentActors: actors.slice(0, 3),
|
||||
timeSpan: {
|
||||
start: notifications[notifications.length - 1].createdAt.toISOString(),
|
||||
end: notifications[0].createdAt.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const aggregation = await this.aggregationRepo.insertAggregation({
|
||||
aggregationKey: key,
|
||||
recipientId: notifications[0].recipientId,
|
||||
aggregationType: this.getAggregationType(type),
|
||||
entityType: notifications[0].entityType,
|
||||
entityId: notifications[0].entityId,
|
||||
actorIds: actors,
|
||||
notificationIds: notificationIds,
|
||||
summaryData: summaryData,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Created aggregation ${aggregation.id} for ${notifications.length} notifications`,
|
||||
);
|
||||
|
||||
return aggregation;
|
||||
}
|
||||
|
||||
private generateAggregationKey(
|
||||
recipientId: string,
|
||||
type: NotificationType,
|
||||
entityId: string,
|
||||
): string {
|
||||
return `${recipientId}:${type}:${entityId}`;
|
||||
}
|
||||
|
||||
private isWithinTimeWindow(updatedAt: Date, timeWindow: number): boolean {
|
||||
return Date.now() - updatedAt.getTime() < timeWindow;
|
||||
}
|
||||
|
||||
private getAggregationType(type: NotificationType): AggregationType {
|
||||
switch (type) {
|
||||
case NotificationType.COMMENT_ON_PAGE:
|
||||
case NotificationType.COMMENT_RESOLVED:
|
||||
return AggregationType.COMMENTS_ON_PAGE;
|
||||
|
||||
case NotificationType.MENTION_IN_PAGE:
|
||||
return AggregationType.MENTIONS_IN_PAGE;
|
||||
|
||||
case NotificationType.MENTION_IN_COMMENT:
|
||||
return AggregationType.MENTIONS_IN_COMMENTS;
|
||||
|
||||
case NotificationType.COMMENT_IN_THREAD:
|
||||
return AggregationType.THREAD_ACTIVITY;
|
||||
|
||||
default:
|
||||
return AggregationType.COMMENTS_ON_PAGE;
|
||||
}
|
||||
}
|
||||
|
||||
async createAggregatedNotificationMessage(
|
||||
aggregation: NotificationAggregation,
|
||||
): Promise<AggregatedNotificationMessage> {
|
||||
// TODO: Load actor information from user service
|
||||
// For now, return a simplified version
|
||||
const actors = aggregation.actorIds.slice(0, 3).map((id) => ({
|
||||
id,
|
||||
name: 'User', // TODO: Load actual user name
|
||||
avatarUrl: undefined,
|
||||
}));
|
||||
|
||||
const primaryActor = actors[0];
|
||||
const otherActorsCount = aggregation.actorIds.length - 1;
|
||||
|
||||
let message: string;
|
||||
let title: string;
|
||||
|
||||
switch (aggregation.aggregationType) {
|
||||
case AggregationType.COMMENTS_ON_PAGE:
|
||||
if (otherActorsCount === 0) {
|
||||
title = `${primaryActor.name} commented on a page`;
|
||||
message = 'View the comment';
|
||||
} else if (otherActorsCount === 1) {
|
||||
title = `${primaryActor.name} and 1 other commented on a page`;
|
||||
message = 'View 2 comments';
|
||||
} else {
|
||||
title = `${primaryActor.name} and ${otherActorsCount} others commented on a page`;
|
||||
message = `View ${aggregation.notificationIds.length} comments`;
|
||||
}
|
||||
break;
|
||||
|
||||
case AggregationType.MENTIONS_IN_PAGE:
|
||||
case AggregationType.MENTIONS_IN_COMMENTS: {
|
||||
const totalMentions = aggregation.notificationIds.length;
|
||||
if (totalMentions === 1) {
|
||||
title = `${primaryActor.name} mentioned you`;
|
||||
message = 'View mention';
|
||||
} else {
|
||||
title = `You were mentioned ${totalMentions} times`;
|
||||
message = `By ${primaryActor.name} and ${otherActorsCount} others`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
title = `${aggregation.notificationIds.length} new notifications`;
|
||||
message = 'View all';
|
||||
}
|
||||
|
||||
return {
|
||||
id: aggregation.id,
|
||||
title,
|
||||
message,
|
||||
actors,
|
||||
totalCount: aggregation.notificationIds.length,
|
||||
entityId: aggregation.entityId,
|
||||
entityType: aggregation.entityType,
|
||||
createdAt: aggregation.createdAt,
|
||||
updatedAt: aggregation.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupOldAggregations(olderThan: Date): Promise<number> {
|
||||
const deletedCount =
|
||||
await this.aggregationRepo.deleteOldAggregations(olderThan);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
this.logger.log(`Cleaned up ${deletedCount} old aggregations`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { NotificationRepo } from '../../../database/repos/notification/notification.repo';
|
||||
import { NotificationBatchRepo } from '../../../database/repos/notification/notification-batch.repo';
|
||||
import { NotificationPreferenceService } from './notification-preference.service';
|
||||
import { Notification } from '@docmost/db/types/entity.types';
|
||||
import { NotificationType, BatchType } from '../types/notification.types';
|
||||
|
||||
interface NotificationGroup {
|
||||
type: NotificationType;
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
notifications: Notification[];
|
||||
actors: Set<string>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationBatchingService {
|
||||
private readonly logger = new Logger(NotificationBatchingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly batchRepo: NotificationBatchRepo,
|
||||
private readonly preferenceService: NotificationPreferenceService,
|
||||
@InjectQueue('notification-batch') private readonly batchQueue: Queue,
|
||||
) {}
|
||||
|
||||
async addToBatch(notification: Notification): Promise<void> {
|
||||
try {
|
||||
const preferences = await this.preferenceService.getUserPreferences(
|
||||
notification.recipientId,
|
||||
notification.workspaceId,
|
||||
);
|
||||
|
||||
const batchKey = this.generateBatchKey(notification);
|
||||
|
||||
// Find or create batch
|
||||
let batch = await this.batchRepo.findByBatchKey(
|
||||
batchKey,
|
||||
notification.recipientId,
|
||||
true, // notSentOnly
|
||||
);
|
||||
|
||||
if (!batch) {
|
||||
// Create new batch
|
||||
const scheduledFor = new Date();
|
||||
scheduledFor.setMinutes(scheduledFor.getMinutes() + preferences.batchWindowMinutes);
|
||||
|
||||
batch = await this.batchRepo.insertBatch({
|
||||
recipientId: notification.recipientId,
|
||||
workspaceId: notification.workspaceId,
|
||||
batchType: BatchType.SIMILAR_ACTIVITY,
|
||||
batchKey: batchKey,
|
||||
notificationCount: 1,
|
||||
firstNotificationId: notification.id,
|
||||
scheduledFor: scheduledFor,
|
||||
});
|
||||
|
||||
// Schedule batch processing
|
||||
await this.batchQueue.add(
|
||||
'process-batch',
|
||||
{ batchId: batch.id },
|
||||
{
|
||||
delay: preferences.batchWindowMinutes * 60 * 1000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Add to existing batch
|
||||
await this.batchRepo.incrementNotificationCount(batch.id);
|
||||
}
|
||||
|
||||
// Update notification with batch ID
|
||||
await this.notificationRepo.updateNotification(notification.id, {
|
||||
batchId: batch.id,
|
||||
isBatched: true,
|
||||
});
|
||||
|
||||
this.logger.debug(`Notification ${notification.id} added to batch ${batch.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to batch notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
// Fall back to instant delivery on error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private generateBatchKey(notification: Notification): string {
|
||||
switch (notification.type) {
|
||||
case NotificationType.COMMENT_ON_PAGE:
|
||||
case NotificationType.COMMENT_RESOLVED: {
|
||||
const context = notification.context as any;
|
||||
return `page:${context?.pageId}:comments`;
|
||||
}
|
||||
|
||||
case NotificationType.MENTION_IN_PAGE:
|
||||
return `page:${notification.entityId}:mentions`;
|
||||
|
||||
case NotificationType.COMMENT_IN_THREAD: {
|
||||
const mentionContext = notification.context as any;
|
||||
return `thread:${mentionContext?.threadRootId}`;
|
||||
}
|
||||
|
||||
default:
|
||||
return `${notification.entityType}:${notification.entityId}:${notification.type}`;
|
||||
}
|
||||
}
|
||||
|
||||
async processBatch(batchId: string): Promise<void> {
|
||||
const batch = await this.batchRepo.findById(batchId);
|
||||
if (!batch || batch.sentAt) {
|
||||
this.logger.debug(`Batch ${batchId} not found or already sent`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await this.notificationRepo.findByBatchId(batchId);
|
||||
|
||||
if (notifications.length === 0) {
|
||||
this.logger.debug(`No notifications found for batch ${batchId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group notifications by type for smart formatting
|
||||
const grouped = this.groupNotificationsByType(notifications);
|
||||
|
||||
// Send batch email
|
||||
await this.sendBatchEmail(batch.recipientId, batch.workspaceId, grouped);
|
||||
|
||||
// Mark batch as sent
|
||||
await this.batchRepo.markAsSent(batchId);
|
||||
|
||||
// Update email sent timestamp for all notifications
|
||||
const notificationIds = notifications.map(n => n.id);
|
||||
await Promise.all(
|
||||
notificationIds.map(id =>
|
||||
this.notificationRepo.updateNotification(id, { emailSentAt: new Date() })
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Batch ${batchId} processed with ${notifications.length} notifications`);
|
||||
}
|
||||
|
||||
private groupNotificationsByType(notifications: Notification[]): NotificationGroup[] {
|
||||
const groups = new Map<string, NotificationGroup>();
|
||||
|
||||
for (const notification of notifications) {
|
||||
const key = `${notification.type}:${notification.entityId}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
type: notification.type as NotificationType,
|
||||
entityId: notification.entityId,
|
||||
entityType: notification.entityType,
|
||||
notifications: [],
|
||||
actors: new Set(),
|
||||
summary: '',
|
||||
});
|
||||
}
|
||||
|
||||
const group = groups.get(key)!;
|
||||
group.notifications.push(notification);
|
||||
if (notification.actorId) {
|
||||
group.actors.add(notification.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summaries for each group
|
||||
for (const group of groups.values()) {
|
||||
group.summary = this.generateSummary(group.type, group.notifications);
|
||||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
private generateSummary(type: NotificationType, notifications: Notification[]): string {
|
||||
const count = notifications.length;
|
||||
const actors = new Set(notifications.map(n => n.actorId).filter(Boolean));
|
||||
|
||||
switch (type) {
|
||||
case NotificationType.COMMENT_ON_PAGE:
|
||||
if (count === 1) return 'commented on a page you follow';
|
||||
return `and ${actors.size - 1} others commented on a page you follow`;
|
||||
|
||||
case NotificationType.MENTION_IN_COMMENT:
|
||||
if (count === 1) return 'mentioned you in a comment';
|
||||
return `mentioned you ${count} times in comments`;
|
||||
|
||||
case NotificationType.COMMENT_RESOLVED:
|
||||
if (count === 1) return 'resolved a comment';
|
||||
return `resolved ${count} comments`;
|
||||
|
||||
default:
|
||||
return `${count} new activities`;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBatchEmail(
|
||||
recipientId: string,
|
||||
workspaceId: string,
|
||||
groups: NotificationGroup[],
|
||||
): Promise<void> {
|
||||
// TODO: Implement email sending with batch template
|
||||
// This will be implemented when we create email templates
|
||||
this.logger.log(
|
||||
`Sending batch email to ${recipientId} with ${groups.length} notification groups`,
|
||||
);
|
||||
}
|
||||
|
||||
async getPendingBatches(): Promise<any[]> {
|
||||
return await this.batchRepo.getPendingBatches();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { NotificationType } from '../types/notification.types';
|
||||
import { CreateNotificationDto } from '../dto/create-notification.dto';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationDeduplicationService {
|
||||
/**
|
||||
* Generate a unique deduplication key based on notification type and context
|
||||
*/
|
||||
generateDeduplicationKey(params: CreateNotificationDto): string | null {
|
||||
switch (params.type) {
|
||||
case NotificationType.MENTION_IN_PAGE:
|
||||
// Only one notification per mention in a page (until page is updated again)
|
||||
return this.hash([
|
||||
'mention',
|
||||
'page',
|
||||
params.entityId,
|
||||
params.actorId,
|
||||
params.recipientId,
|
||||
]);
|
||||
|
||||
case NotificationType.MENTION_IN_COMMENT:
|
||||
// One notification per comment mention
|
||||
return this.hash([
|
||||
'mention',
|
||||
'comment',
|
||||
params.entityId,
|
||||
params.actorId,
|
||||
params.recipientId,
|
||||
]);
|
||||
|
||||
case NotificationType.COMMENT_ON_PAGE:
|
||||
// Allow multiple notifications for different comments on the same page
|
||||
return null; // No deduplication, rely on batching instead
|
||||
|
||||
case NotificationType.REPLY_TO_COMMENT:
|
||||
// One notification per reply
|
||||
return this.hash([
|
||||
'reply',
|
||||
params.entityId,
|
||||
params.actorId,
|
||||
params.recipientId,
|
||||
]);
|
||||
|
||||
case NotificationType.COMMENT_RESOLVED:
|
||||
// One notification per comment resolution
|
||||
return this.hash([
|
||||
'resolved',
|
||||
params.context.commentId,
|
||||
params.actorId,
|
||||
params.recipientId,
|
||||
]);
|
||||
|
||||
case NotificationType.EXPORT_COMPLETED:
|
||||
case NotificationType.EXPORT_FAILED:
|
||||
// One notification per export job
|
||||
return this.hash([
|
||||
'export',
|
||||
params.context.jobId || params.entityId,
|
||||
params.recipientId,
|
||||
]);
|
||||
|
||||
case NotificationType.PAGE_SHARED:
|
||||
// One notification per page share action
|
||||
return this.hash([
|
||||
'share',
|
||||
params.entityId,
|
||||
params.actorId,
|
||||
params.recipientId,
|
||||
Date.now().toString(), // Include timestamp to allow re-sharing
|
||||
]);
|
||||
|
||||
default:
|
||||
// For other types, generate a key based on common fields
|
||||
return this.hash([
|
||||
params.type,
|
||||
params.entityId,
|
||||
params.actorId,
|
||||
params.recipientId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a notification should be deduplicated based on recent activity
|
||||
*/
|
||||
shouldDeduplicate(type: NotificationType): boolean {
|
||||
const deduplicatedTypes = [
|
||||
NotificationType.MENTION_IN_PAGE,
|
||||
NotificationType.MENTION_IN_COMMENT,
|
||||
NotificationType.REPLY_TO_COMMENT,
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
NotificationType.EXPORT_COMPLETED,
|
||||
NotificationType.EXPORT_FAILED,
|
||||
];
|
||||
|
||||
return deduplicatedTypes.includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time window for deduplication (in milliseconds)
|
||||
*/
|
||||
getDeduplicationWindow(type: NotificationType): number {
|
||||
switch (type) {
|
||||
case NotificationType.MENTION_IN_PAGE:
|
||||
return 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
case NotificationType.MENTION_IN_COMMENT:
|
||||
return 60 * 60 * 1000; // 1 hour
|
||||
|
||||
case NotificationType.EXPORT_COMPLETED:
|
||||
case NotificationType.EXPORT_FAILED:
|
||||
return 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
default:
|
||||
return 30 * 60 * 1000; // 30 minutes default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash from array of values
|
||||
*/
|
||||
private hash(values: (string | null | undefined)[]): string {
|
||||
const filtered = values.filter((v) => v !== null && v !== undefined);
|
||||
const input = filtered.join(':');
|
||||
return createHash('sha256').update(input).digest('hex').substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a key for custom deduplication scenarios
|
||||
*/
|
||||
generateCustomKey(
|
||||
type: string,
|
||||
entityId: string,
|
||||
recipientId: string,
|
||||
additionalData?: Record<string, any>,
|
||||
): string {
|
||||
const baseValues = [type, entityId, recipientId];
|
||||
|
||||
if (additionalData) {
|
||||
// Sort keys for consistent hashing
|
||||
const sortedKeys = Object.keys(additionalData).sort();
|
||||
for (const key of sortedKeys) {
|
||||
baseValues.push(`${key}:${additionalData[key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.hash(baseValues);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueName } from '../../../integrations/queue/constants';
|
||||
import { WsGateway } from '../../../ws/ws.gateway';
|
||||
import { NotificationBatchingService } from './notification-batching.service';
|
||||
import { NotificationPreferenceService } from './notification-preference.service';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { Notification } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
NotificationCreatedEvent,
|
||||
NotificationReadEvent,
|
||||
NotificationAllReadEvent,
|
||||
NOTIFICATION_EVENTS,
|
||||
} from '../events/notification.events';
|
||||
import { NotificationType, NotificationPriority } from '../types/notification.types';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationDeliveryService {
|
||||
private readonly logger = new Logger(NotificationDeliveryService.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QueueName.EMAIL_QUEUE) private readonly mailQueue: Queue,
|
||||
private readonly wsGateway: WsGateway,
|
||||
private readonly batchingService: NotificationBatchingService,
|
||||
private readonly preferenceService: NotificationPreferenceService,
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
) {}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENTS.CREATED)
|
||||
async handleNotificationCreated(event: NotificationCreatedEvent) {
|
||||
const { notification, workspaceId } = event;
|
||||
|
||||
try {
|
||||
const decision = await this.preferenceService.makeNotificationDecision(
|
||||
notification.recipientId,
|
||||
workspaceId,
|
||||
notification.type as NotificationType,
|
||||
notification.priority as NotificationPriority,
|
||||
);
|
||||
|
||||
// In-app delivery (always immediate)
|
||||
if (decision.channels.includes('in_app')) {
|
||||
await this.deliverInApp(notification, workspaceId);
|
||||
}
|
||||
|
||||
// Email delivery (may be batched)
|
||||
if (decision.channels.includes('email')) {
|
||||
if (decision.batchingEnabled) {
|
||||
await this.batchingService.addToBatch(notification);
|
||||
} else {
|
||||
await this.deliverEmailInstant(notification);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to deliver notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverInApp(notification: Notification, workspaceId: string) {
|
||||
try {
|
||||
// Send notification via WebSocket to user's workspace room
|
||||
const notificationData = {
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
status: notification.status,
|
||||
priority: notification.priority,
|
||||
actorId: notification.actorId,
|
||||
entityType: notification.entityType,
|
||||
entityId: notification.entityId,
|
||||
context: notification.context,
|
||||
createdAt: notification.createdAt,
|
||||
};
|
||||
|
||||
// Emit to user-specific room
|
||||
this.wsGateway.emitToUser(
|
||||
notification.recipientId,
|
||||
'notification:new',
|
||||
notificationData,
|
||||
);
|
||||
|
||||
// Update unread count
|
||||
const unreadCount = await this.notificationRepo.getUnreadCount(
|
||||
notification.recipientId,
|
||||
);
|
||||
this.wsGateway.emitToUser(
|
||||
notification.recipientId,
|
||||
'notification:unreadCount',
|
||||
{ count: unreadCount },
|
||||
);
|
||||
|
||||
// Update delivery status
|
||||
await this.notificationRepo.updateNotification(notification.id, {
|
||||
inAppDeliveredAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.debug(`In-app notification delivered: ${notification.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to deliver in-app notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverEmailInstant(notification: Notification) {
|
||||
try {
|
||||
await this.mailQueue.add(
|
||||
'send-notification-email',
|
||||
{
|
||||
notificationId: notification.id,
|
||||
type: notification.type,
|
||||
},
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.debug(`Email notification queued: ${notification.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to queue email notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENTS.READ)
|
||||
async handleNotificationRead(event: NotificationReadEvent) {
|
||||
const { notificationId, userId } = event;
|
||||
|
||||
// Send real-time update to user
|
||||
this.wsGateway.emitToUser(userId, 'notification:read', {
|
||||
notificationId,
|
||||
});
|
||||
|
||||
// Update unread count
|
||||
const unreadCount = await this.notificationRepo.getUnreadCount(userId);
|
||||
this.wsGateway.emitToUser(userId, 'notification:unreadCount', {
|
||||
count: unreadCount,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENTS.ALL_READ)
|
||||
async handleAllNotificationsRead(event: NotificationAllReadEvent) {
|
||||
const { userId, notificationIds } = event;
|
||||
|
||||
// Send real-time update to user
|
||||
this.wsGateway.emitToUser(userId, 'notification:allRead', {
|
||||
notificationIds,
|
||||
});
|
||||
|
||||
// Update unread count (should be 0)
|
||||
this.wsGateway.emitToUser(userId, 'notification:unreadCount', { count: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Process email delivery for a notification
|
||||
* Called by the mail queue processor
|
||||
*/
|
||||
async processEmailNotification(notificationId: string) {
|
||||
const notification = await this.notificationRepo.findById(notificationId);
|
||||
if (!notification) {
|
||||
throw new Error(`Notification not found: ${notificationId}`);
|
||||
}
|
||||
|
||||
// Check if already sent
|
||||
if (notification.emailSentAt) {
|
||||
this.logger.debug(
|
||||
`Notification already sent via email: ${notificationId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Load user and workspace data
|
||||
// TODO: Render appropriate email template based on notification type
|
||||
// TODO: Send email using mail service
|
||||
|
||||
// For now, just mark as sent
|
||||
await this.notificationRepo.updateNotification(notificationId, {
|
||||
emailSentAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log(`Email notification sent: ${notificationId}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,303 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotificationPreferenceRepo } from '@docmost/db/repos/notification/notification-preference.repo';
|
||||
import { UpdateNotificationPreferencesDto } from '../dto/update-preference.dto';
|
||||
import { NotificationPreference } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
} from '../types/notification.types';
|
||||
import {
|
||||
addDays,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
getDay,
|
||||
differenceInMilliseconds,
|
||||
startOfDay,
|
||||
addHours
|
||||
} from 'date-fns';
|
||||
|
||||
interface NotificationDecision {
|
||||
shouldNotify: boolean;
|
||||
channels: ('email' | 'in_app')[];
|
||||
delay?: number;
|
||||
batchingEnabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationPreferenceService {
|
||||
private readonly logger = new Logger(NotificationPreferenceService.name);
|
||||
|
||||
constructor(private readonly preferenceRepo: NotificationPreferenceRepo) {}
|
||||
|
||||
async getUserPreferences(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<NotificationPreference> {
|
||||
return await this.preferenceRepo.findOrCreate(userId, workspaceId);
|
||||
}
|
||||
|
||||
async updateUserPreferences(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
updates: UpdateNotificationPreferencesDto,
|
||||
): Promise<NotificationPreference> {
|
||||
const existing = await this.getUserPreferences(userId, workspaceId);
|
||||
|
||||
// Merge notification settings if provided
|
||||
let mergedSettings = existing.notificationSettings;
|
||||
if (updates.notificationSettings) {
|
||||
mergedSettings = {
|
||||
...((existing.notificationSettings as Record<string, any>) || {}),
|
||||
...(updates.notificationSettings || {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate batch window
|
||||
if (updates.batchWindowMinutes !== undefined) {
|
||||
updates.batchWindowMinutes = Math.max(
|
||||
5,
|
||||
Math.min(60, updates.batchWindowMinutes),
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.preferenceRepo.updatePreference(
|
||||
userId,
|
||||
workspaceId,
|
||||
{
|
||||
...updates,
|
||||
notificationSettings: mergedSettings,
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`User ${userId} updated notification preferences`, {
|
||||
userId,
|
||||
workspaceId,
|
||||
changes: updates,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async shouldNotify(
|
||||
recipientId: string,
|
||||
type: NotificationType,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const preferences = await this.getUserPreferences(recipientId, workspaceId);
|
||||
const decision = await this.makeNotificationDecision(
|
||||
recipientId,
|
||||
workspaceId,
|
||||
type,
|
||||
NotificationPriority.NORMAL,
|
||||
);
|
||||
|
||||
return decision.shouldNotify;
|
||||
}
|
||||
|
||||
async makeNotificationDecision(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
type: NotificationType,
|
||||
priority: NotificationPriority = NotificationPriority.NORMAL,
|
||||
): Promise<NotificationDecision> {
|
||||
const preferences = await this.getUserPreferences(userId, workspaceId);
|
||||
|
||||
// Global check
|
||||
if (!preferences.emailEnabled && !preferences.inAppEnabled) {
|
||||
return {
|
||||
shouldNotify: false,
|
||||
channels: [],
|
||||
batchingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Type-specific settings
|
||||
const typeSettings = this.getTypeSettings(preferences, type);
|
||||
|
||||
const channels: ('email' | 'in_app')[] = [];
|
||||
if (preferences.emailEnabled && typeSettings.email) channels.push('email');
|
||||
if (preferences.inAppEnabled && typeSettings.in_app)
|
||||
channels.push('in_app');
|
||||
|
||||
if (channels.length === 0) {
|
||||
return {
|
||||
shouldNotify: false,
|
||||
channels: [],
|
||||
batchingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
const quietHoursDelay = this.calculateQuietHoursDelay(
|
||||
preferences,
|
||||
priority,
|
||||
);
|
||||
|
||||
// Check weekend preferences
|
||||
if (
|
||||
!preferences.weekendNotifications &&
|
||||
this.isWeekend(preferences.timezone)
|
||||
) {
|
||||
if (priority !== NotificationPriority.HIGH) {
|
||||
const mondayDelay = this.getDelayUntilMonday(preferences.timezone);
|
||||
return {
|
||||
shouldNotify: true,
|
||||
channels,
|
||||
delay: mondayDelay,
|
||||
batchingEnabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldNotify: true,
|
||||
channels,
|
||||
delay: quietHoursDelay,
|
||||
batchingEnabled:
|
||||
typeSettings.batch && preferences.emailFrequency === 'smart',
|
||||
};
|
||||
}
|
||||
|
||||
private getTypeSettings(
|
||||
preferences: NotificationPreference,
|
||||
type: NotificationType,
|
||||
): any {
|
||||
const settings = preferences.notificationSettings as any;
|
||||
return settings[type] || { email: true, in_app: true, batch: false };
|
||||
}
|
||||
|
||||
private calculateQuietHoursDelay(
|
||||
preferences: NotificationPreference,
|
||||
priority: NotificationPriority,
|
||||
): number | undefined {
|
||||
if (
|
||||
!preferences.quietHoursEnabled ||
|
||||
priority === NotificationPriority.HIGH
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: Implement proper timezone conversion
|
||||
const now = new Date();
|
||||
const quietStart = this.parseTime(
|
||||
preferences.quietHoursStart,
|
||||
preferences.timezone,
|
||||
);
|
||||
const quietEnd = this.parseTime(
|
||||
preferences.quietHoursEnd,
|
||||
preferences.timezone,
|
||||
);
|
||||
|
||||
if (this.isInQuietHours(now, quietStart, quietEnd)) {
|
||||
return this.getDelayUntilEndOfQuietHours(now, quietEnd);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseTime(timeStr: string, timezone: string): Date {
|
||||
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
|
||||
// TODO: Implement proper timezone conversion
|
||||
const now = new Date();
|
||||
return setSeconds(setMinutes(setHours(now, hours), minutes), seconds || 0);
|
||||
}
|
||||
|
||||
private isInQuietHours(
|
||||
now: Date,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): boolean {
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
if (startMinutes <= endMinutes) {
|
||||
// Quiet hours don't cross midnight
|
||||
return nowMinutes >= startMinutes && nowMinutes < endMinutes;
|
||||
} else {
|
||||
// Quiet hours cross midnight
|
||||
return nowMinutes >= startMinutes || nowMinutes < endMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
private getDelayUntilEndOfQuietHours(now: Date, end: Date): number {
|
||||
let endTime = end;
|
||||
|
||||
// If end time is before current time, it means quiet hours end tomorrow
|
||||
if (
|
||||
end.getHours() < now.getHours() ||
|
||||
(end.getHours() === now.getHours() && end.getMinutes() <= now.getMinutes())
|
||||
) {
|
||||
endTime = addDays(endTime, 1);
|
||||
}
|
||||
|
||||
return differenceInMilliseconds(endTime, now);
|
||||
}
|
||||
|
||||
private isWeekend(timezone: string): boolean {
|
||||
// TODO: Implement proper timezone conversion
|
||||
const now = new Date();
|
||||
const dayOfWeek = getDay(now);
|
||||
return dayOfWeek === 0 || dayOfWeek === 6; // 0 = Sunday, 6 = Saturday
|
||||
}
|
||||
|
||||
private getDelayUntilMonday(timezone: string): number {
|
||||
// TODO: Implement proper timezone conversion
|
||||
const now = new Date();
|
||||
const currentDay = getDay(now);
|
||||
const daysUntilMonday = currentDay === 0 ? 1 : (8 - currentDay) % 7 || 7;
|
||||
const nextMonday = addDays(now, daysUntilMonday);
|
||||
const mondayMorning = addHours(startOfDay(nextMonday), 9); // 9 AM Monday
|
||||
return differenceInMilliseconds(mondayMorning, now);
|
||||
}
|
||||
|
||||
async getNotificationStats(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<{
|
||||
preferences: NotificationPreference;
|
||||
stats: {
|
||||
emailEnabled: boolean;
|
||||
inAppEnabled: boolean;
|
||||
quietHoursActive: boolean;
|
||||
batchingEnabled: boolean;
|
||||
typesDisabled: string[];
|
||||
};
|
||||
}> {
|
||||
const preferences = await this.getUserPreferences(userId, workspaceId);
|
||||
// TODO: Implement proper timezone conversion
|
||||
const now = new Date();
|
||||
const quietStart = this.parseTime(
|
||||
preferences.quietHoursStart,
|
||||
preferences.timezone,
|
||||
);
|
||||
const quietEnd = this.parseTime(
|
||||
preferences.quietHoursEnd,
|
||||
preferences.timezone,
|
||||
);
|
||||
|
||||
const typesDisabled: string[] = [];
|
||||
const settings = preferences.notificationSettings as any;
|
||||
|
||||
for (const [type, config] of Object.entries(settings)) {
|
||||
const typeSettings = config as any;
|
||||
if (!typeSettings.email && !typeSettings.in_app) {
|
||||
typesDisabled.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
preferences,
|
||||
stats: {
|
||||
emailEnabled: preferences.emailEnabled,
|
||||
inAppEnabled: preferences.inAppEnabled,
|
||||
quietHoursActive:
|
||||
preferences.quietHoursEnabled &&
|
||||
this.isInQuietHours(now, quietStart, quietEnd),
|
||||
batchingEnabled: preferences.emailFrequency !== 'instant',
|
||||
typesDisabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,275 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { NotificationDeduplicationService } from './notification-deduplication.service';
|
||||
import { NotificationPreferenceService } from './notification-preference.service';
|
||||
import { CreateNotificationDto } from '../dto/create-notification.dto';
|
||||
import { Notification } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
NotificationStatus,
|
||||
NotificationPriority,
|
||||
NotificationType,
|
||||
} from '../types/notification.types';
|
||||
import {
|
||||
NotificationCreatedEvent,
|
||||
NotificationReadEvent,
|
||||
NotificationAllReadEvent,
|
||||
NOTIFICATION_EVENTS,
|
||||
} from '../events/notification.events';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly deduplicationService: NotificationDeduplicationService,
|
||||
private readonly preferenceService: NotificationPreferenceService,
|
||||
) {}
|
||||
|
||||
async createNotification(
|
||||
dto: CreateNotificationDto,
|
||||
): Promise<Notification | null> {
|
||||
try {
|
||||
// Set default priority if not provided
|
||||
const priority = dto.priority || NotificationPriority.NORMAL;
|
||||
|
||||
// Check user preferences first
|
||||
const decision = await this.preferenceService.makeNotificationDecision(
|
||||
dto.recipientId,
|
||||
dto.workspaceId,
|
||||
dto.type,
|
||||
priority,
|
||||
);
|
||||
|
||||
if (!decision.shouldNotify) {
|
||||
this.logger.debug(
|
||||
`Notification blocked by user preferences: ${dto.type} for ${dto.recipientId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate deduplication key
|
||||
let deduplicationKey = dto.deduplicationKey;
|
||||
if (
|
||||
!deduplicationKey &&
|
||||
this.deduplicationService.shouldDeduplicate(dto.type)
|
||||
) {
|
||||
deduplicationKey =
|
||||
this.deduplicationService.generateDeduplicationKey(dto);
|
||||
}
|
||||
|
||||
// Check if duplicate
|
||||
if (
|
||||
deduplicationKey &&
|
||||
(await this.notificationRepo.existsByDeduplicationKey(deduplicationKey))
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Duplicate notification prevented: ${deduplicationKey}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate group key if not provided
|
||||
const groupKey = dto.groupKey || this.generateGroupKey(dto);
|
||||
|
||||
// Calculate expiration
|
||||
const expiresAt = this.calculateExpiration(dto.type);
|
||||
|
||||
// Create notification
|
||||
const notification = await this.notificationRepo.insertNotification({
|
||||
workspaceId: dto.workspaceId,
|
||||
recipientId: dto.recipientId,
|
||||
actorId: dto.actorId || null,
|
||||
type: dto.type,
|
||||
status: NotificationStatus.UNREAD,
|
||||
priority,
|
||||
entityType: dto.entityType,
|
||||
entityId: dto.entityId,
|
||||
context: dto.context,
|
||||
groupKey: groupKey,
|
||||
groupCount: 1,
|
||||
deduplicationKey: deduplicationKey,
|
||||
batchId: null,
|
||||
isBatched: false,
|
||||
emailSentAt: null,
|
||||
inAppDeliveredAt: null,
|
||||
readAt: null,
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
|
||||
// Emit event for delivery processing
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENTS.CREATED,
|
||||
new NotificationCreatedEvent(notification, dto.workspaceId),
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Notification created: ${notification.id} for user ${dto.recipientId}`,
|
||||
);
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create notification: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNotifications(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
options: {
|
||||
status?: NotificationStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
): Promise<Notification[]> {
|
||||
return await this.notificationRepo.findByRecipient(userId, options);
|
||||
}
|
||||
|
||||
async getGroupedNotifications(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
options: {
|
||||
status?: NotificationStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
): Promise<{
|
||||
notifications: Notification[];
|
||||
groups: Map<string, Notification[]>;
|
||||
}> {
|
||||
const notifications = await this.getNotifications(
|
||||
userId,
|
||||
workspaceId,
|
||||
options,
|
||||
);
|
||||
|
||||
// Group notifications by group_key
|
||||
const groups = new Map<string, Notification[]>();
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (notification.groupKey) {
|
||||
const group = groups.get(notification.groupKey) || [];
|
||||
group.push(notification);
|
||||
groups.set(notification.groupKey, group);
|
||||
}
|
||||
}
|
||||
|
||||
return { notifications, groups };
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string, userId: string): Promise<void> {
|
||||
const notification = await this.notificationRepo.findById(notificationId);
|
||||
|
||||
if (!notification || notification.recipientId !== userId) {
|
||||
throw new Error('Notification not found or unauthorized');
|
||||
}
|
||||
|
||||
if (notification.status === NotificationStatus.READ) {
|
||||
return; // Already read
|
||||
}
|
||||
|
||||
await this.notificationRepo.markAsRead(notificationId);
|
||||
|
||||
// Emit event for real-time update
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENTS.READ,
|
||||
new NotificationReadEvent(notificationId, userId),
|
||||
);
|
||||
|
||||
this.logger.debug(`Notification marked as read: ${notificationId}`);
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
const unreadNotifications = await this.notificationRepo.findByRecipient(
|
||||
userId,
|
||||
{
|
||||
status: NotificationStatus.UNREAD,
|
||||
},
|
||||
);
|
||||
|
||||
const ids = unreadNotifications.map((n) => n.id);
|
||||
|
||||
if (ids.length > 0) {
|
||||
await this.notificationRepo.markManyAsRead(ids);
|
||||
|
||||
// Emit event for real-time update
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENTS.ALL_READ,
|
||||
new NotificationAllReadEvent(userId, ids),
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Marked ${ids.length} notifications as read for user ${userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
return await this.notificationRepo.getUnreadCount(userId);
|
||||
}
|
||||
|
||||
async deleteExpiredNotifications(): Promise<number> {
|
||||
const deletedCount = await this.notificationRepo.deleteExpired();
|
||||
|
||||
if (deletedCount > 0) {
|
||||
this.logger.log(`Deleted ${deletedCount} expired notifications`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
private generateGroupKey(dto: CreateNotificationDto): string {
|
||||
// Generate a group key based on notification type and entity
|
||||
return `${dto.type}:${dto.entityType}:${dto.entityId}`;
|
||||
}
|
||||
|
||||
private calculateExpiration(type: string): Date | null {
|
||||
// Set expiration based on notification type
|
||||
const expirationDays = {
|
||||
[NotificationType.EXPORT_COMPLETED]: 7, // Expire after 7 days
|
||||
[NotificationType.EXPORT_FAILED]: 3, // Expire after 3 days
|
||||
[NotificationType.MENTION_IN_PAGE]: 30, // Expire after 30 days
|
||||
[NotificationType.MENTION_IN_COMMENT]: 30,
|
||||
[NotificationType.COMMENT_ON_PAGE]: 60, // Expire after 60 days
|
||||
[NotificationType.REPLY_TO_COMMENT]: 60,
|
||||
[NotificationType.COMMENT_IN_THREAD]: 60,
|
||||
[NotificationType.COMMENT_RESOLVED]: 90, // Expire after 90 days
|
||||
[NotificationType.PAGE_SHARED]: 90,
|
||||
};
|
||||
|
||||
const days = expirationDays[type as NotificationType];
|
||||
if (!days) {
|
||||
return null; // No expiration
|
||||
}
|
||||
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + days);
|
||||
return expirationDate;
|
||||
}
|
||||
|
||||
async createTestNotification(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
type: NotificationType,
|
||||
): Promise<Notification | null> {
|
||||
return await this.createNotification({
|
||||
workspaceId,
|
||||
recipientId: userId,
|
||||
actorId: userId,
|
||||
type,
|
||||
entityType: 'test',
|
||||
entityId: 'test-notification',
|
||||
context: {
|
||||
message: 'This is a test notification',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
priority: NotificationPriority.NORMAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Section, Text, Link, Hr, Heading } from '@react-email/components';
|
||||
import { MailBody } from '@docmost/transactional/partials/partials';
|
||||
import { content, paragraph, button, h1 } from '@docmost/transactional/css/styles';
|
||||
|
||||
interface NotificationGroup {
|
||||
type: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
count: number;
|
||||
actors: string[];
|
||||
url: string;
|
||||
preview: string[];
|
||||
}
|
||||
|
||||
interface BatchNotificationEmailProps {
|
||||
recipientName: string;
|
||||
groups: NotificationGroup[];
|
||||
totalCount: number;
|
||||
workspaceName: string;
|
||||
settingsUrl: string;
|
||||
viewAllUrl: string;
|
||||
}
|
||||
|
||||
export const BatchNotificationEmail = ({
|
||||
recipientName,
|
||||
groups,
|
||||
totalCount,
|
||||
workspaceName,
|
||||
settingsUrl,
|
||||
viewAllUrl,
|
||||
}: BatchNotificationEmailProps) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={h1}>Hi {recipientName},</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
You have {totalCount} new notifications in {workspaceName}:
|
||||
</Text>
|
||||
|
||||
{groups.map((group, index) => (
|
||||
<Section key={index} style={notificationGroup}>
|
||||
<Heading as="h3" style={groupTitle}>
|
||||
{group.title}
|
||||
</Heading>
|
||||
|
||||
<Text style={actorList}>
|
||||
{formatActors(group.actors)} {group.summary}
|
||||
</Text>
|
||||
|
||||
{group.preview.slice(0, 3).map((item, i) => (
|
||||
<Text key={i} style={notificationItem}>
|
||||
• {item}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{group.count > 3 && (
|
||||
<Text style={moreText}>
|
||||
And {group.count - 3} more...
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button href={group.url} style={viewButton}>
|
||||
View All
|
||||
</Button>
|
||||
</Section>
|
||||
))}
|
||||
|
||||
<Hr style={divider} />
|
||||
|
||||
<Button href={viewAllUrl} style={viewAllButton}>
|
||||
View All Notifications
|
||||
</Button>
|
||||
|
||||
<Text style={footerText}>
|
||||
You received this because you have smart notifications enabled.{' '}
|
||||
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
|
||||
Manage your preferences
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
function formatActors(actors: string[]): string {
|
||||
if (actors.length === 0) return '';
|
||||
if (actors.length === 1) return actors[0];
|
||||
if (actors.length === 2) return `${actors[0]} and ${actors[1]}`;
|
||||
return `${actors[0]}, ${actors[1]} and ${actors.length - 2} others`;
|
||||
}
|
||||
|
||||
const notificationGroup: React.CSSProperties = {
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
};
|
||||
|
||||
const groupTitle: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
};
|
||||
|
||||
const actorList: React.CSSProperties = {
|
||||
...paragraph,
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const notificationItem: React.CSSProperties = {
|
||||
...paragraph,
|
||||
marginLeft: '8px',
|
||||
marginBottom: '4px',
|
||||
color: '#666',
|
||||
};
|
||||
|
||||
const moreText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontStyle: 'italic',
|
||||
color: '#999',
|
||||
marginLeft: '8px',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const viewButton: React.CSSProperties = {
|
||||
...button,
|
||||
width: 'auto',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
marginTop: '8px',
|
||||
};
|
||||
|
||||
const viewAllButton: React.CSSProperties = {
|
||||
...button,
|
||||
width: 'auto',
|
||||
padding: '12px 24px',
|
||||
margin: '16px auto',
|
||||
};
|
||||
|
||||
const divider: React.CSSProperties = {
|
||||
borderColor: '#e0e0e0',
|
||||
margin: '24px 0',
|
||||
};
|
||||
|
||||
const footerText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '24px',
|
||||
};
|
||||
@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Section, Text, Link } from '@react-email/components';
|
||||
import { MailBody } from '../../../integrations/transactional/partials/partials';
|
||||
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
|
||||
|
||||
interface CommentOnPageEmailProps {
|
||||
recipientName: string;
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
commentExcerpt: string;
|
||||
pageUrl: string;
|
||||
workspaceName: string;
|
||||
settingsUrl: string;
|
||||
}
|
||||
|
||||
export const CommentOnPageEmail = ({
|
||||
recipientName,
|
||||
actorName,
|
||||
pageTitle,
|
||||
commentExcerpt,
|
||||
pageUrl,
|
||||
workspaceName,
|
||||
settingsUrl,
|
||||
}: CommentOnPageEmailProps) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={h1}>Hi {recipientName},</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
{actorName} commented on "{pageTitle}":
|
||||
</Text>
|
||||
|
||||
<Section style={commentSection}>
|
||||
<Text style={commentText}>
|
||||
{commentExcerpt}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Button href={pageUrl} style={button}>
|
||||
View Comment
|
||||
</Button>
|
||||
|
||||
<Text style={footerText}>
|
||||
This notification was sent from {workspaceName}.{' '}
|
||||
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
|
||||
Manage your notification preferences
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
const commentSection: React.CSSProperties = {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const commentText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const footerText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '24px',
|
||||
};
|
||||
@ -0,0 +1,117 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Section, Text, Link, Row, Column } from '@react-email/components';
|
||||
import { MailBody } from '../../../integrations/transactional/partials/partials';
|
||||
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
|
||||
|
||||
interface ExportCompletedEmailProps {
|
||||
recipientName: string;
|
||||
exportType: string;
|
||||
entityName: string;
|
||||
fileSize: string;
|
||||
downloadUrl: string;
|
||||
expiresAt: string;
|
||||
workspaceName: string;
|
||||
settingsUrl: string;
|
||||
}
|
||||
|
||||
export const ExportCompletedEmail = ({
|
||||
recipientName,
|
||||
exportType,
|
||||
entityName,
|
||||
fileSize,
|
||||
downloadUrl,
|
||||
expiresAt,
|
||||
workspaceName,
|
||||
settingsUrl,
|
||||
}: ExportCompletedEmailProps) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={h1}>Export Complete!</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
Hi {recipientName},
|
||||
</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
Your {exportType.toUpperCase()} export of "{entityName}" has been completed successfully.
|
||||
</Text>
|
||||
|
||||
<Section style={exportDetails}>
|
||||
<Row>
|
||||
<Column style={detailLabel}>File Size:</Column>
|
||||
<Column style={detailValue}>{fileSize}</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={detailLabel}>Format:</Column>
|
||||
<Column style={detailValue}>{exportType.toUpperCase()}</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={detailLabel}>Expires:</Column>
|
||||
<Column style={detailValue}>{expiresAt}</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Button href={downloadUrl} style={downloadButton}>
|
||||
Download Export
|
||||
</Button>
|
||||
|
||||
<Text style={warningText}>
|
||||
⚠️ This download link will expire on {expiresAt}.
|
||||
Please download your file before then.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
This notification was sent from {workspaceName}.{' '}
|
||||
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
|
||||
Manage your notification preferences
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
const exportDetails: React.CSSProperties = {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const detailLabel: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontWeight: 'bold',
|
||||
width: '120px',
|
||||
paddingBottom: '8px',
|
||||
};
|
||||
|
||||
const detailValue: React.CSSProperties = {
|
||||
...paragraph,
|
||||
paddingBottom: '8px',
|
||||
};
|
||||
|
||||
const downloadButton: React.CSSProperties = {
|
||||
...button,
|
||||
backgroundColor: '#28a745',
|
||||
width: 'auto',
|
||||
padding: '12px 24px',
|
||||
margin: '0 auto',
|
||||
};
|
||||
|
||||
const warningText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffeeba',
|
||||
borderRadius: '4px',
|
||||
color: '#856404',
|
||||
padding: '12px',
|
||||
marginTop: '16px',
|
||||
};
|
||||
|
||||
const footerText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '24px',
|
||||
};
|
||||
@ -0,0 +1,93 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Section, Text, Link } from '@react-email/components';
|
||||
import { MailBody } from '../../../integrations/transactional/partials/partials';
|
||||
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
|
||||
|
||||
interface MentionInCommentEmailProps {
|
||||
recipientName: string;
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
commentExcerpt: string;
|
||||
mentionContext: string;
|
||||
commentUrl: string;
|
||||
workspaceName: string;
|
||||
settingsUrl: string;
|
||||
}
|
||||
|
||||
export const MentionInCommentEmail = ({
|
||||
recipientName,
|
||||
actorName,
|
||||
pageTitle,
|
||||
commentExcerpt,
|
||||
mentionContext,
|
||||
commentUrl,
|
||||
workspaceName,
|
||||
settingsUrl,
|
||||
}: MentionInCommentEmailProps) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={h1}>Hi {recipientName},</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
{actorName} mentioned you in a comment on "{pageTitle}":
|
||||
</Text>
|
||||
|
||||
<Section style={commentSection}>
|
||||
<Text style={commentAuthor}>{actorName} commented:</Text>
|
||||
<Text style={commentText}>
|
||||
{commentExcerpt}
|
||||
</Text>
|
||||
{mentionContext && (
|
||||
<Text style={mentionHighlight}>
|
||||
Context: ...{mentionContext}...
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Button href={commentUrl} style={button}>
|
||||
View Comment
|
||||
</Button>
|
||||
|
||||
<Text style={footerText}>
|
||||
This notification was sent from {workspaceName}.{' '}
|
||||
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
|
||||
Manage your notification preferences
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
const commentSection: React.CSSProperties = {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const commentAuthor: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
};
|
||||
|
||||
const commentText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
margin: '0 0 8px 0',
|
||||
};
|
||||
|
||||
const mentionHighlight: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontStyle: 'italic',
|
||||
color: '#666',
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const footerText: React.CSSProperties = {
|
||||
...paragraph,
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '24px',
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user