Compare commits

...

73 Commits

Author SHA1 Message Date
7be077fb93 fix foreign keys 2025-07-29 13:33:54 -07:00
1895c51d10 Merge branch 'main' into feat/resolve-comment 2025-07-29 13:22:23 -07:00
ec12e80423 feat: trash for deleted pages in space (#325)
* initial commit

* added recycle bin modal, updated api routes

* updated page service & controller, recycle bin modal

* updated page-query.ts, use-tree-mutation.ts, recycled-pages.ts

* removed quotes from openRestorePageModal prompt

* Updated page.repo.ts

* move button to space menu

* fix react issues

* opted to reload to enact changes in the client

* lint

* hide deleted pages in recents, handle restore child page

* fix null check

* WIP

* WIP

* feat: implement dedicated trash page
- Replace modal-based trash view with dedicated route `/s/:spaceSlug/trash`
- Add pagination support for deleted pages
- Other improvements

* fix translation

* trash cleanup cron

* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-07-29 21:20:49 +01:00
28fcb11cb4 update passport-saml (#1418) 2025-07-29 19:30:53 +01:00
6b627d289c fix xss in generic iframe embed (#1419) 2025-07-29 19:28:48 +01:00
78bce0e29d fix: validate public avatar path (#1416) 2025-07-28 18:17:06 +01:00
0bd7ecb9b0 feat: enhance table cells with rich content support (#1409)
- Support multiple content types in table cells and headers: paragraphs, headings, lists (bullet/ordered/task), blockquotes, callouts, images, videos, attachments, math blocks, toggles, and code blocks
- Add custom table extension with smart Tab key handling for list indentation within tables
- Preserve default table navigation when not in lists
2025-07-28 08:22:22 +01:00
1f815880a4 Revert "feat: set mermaid theme based on computed color scheme (#1397)" (#1412)
This reverts commit 32c7ecd9cf.
2025-07-26 01:34:15 +01:00
03a58c2969 feat: add space_id to comments and allow space admins to delete any comment
- Add space_id column to comments table with data migration from pages
- Add last_edited_by_id, resolved_by_id, and updated_at columns to comments
- Update comment deletion permissions to allow space admins to delete any comment
- Backfill space_id on old comments
2025-07-25 01:00:12 -07:00
f9317b99a9 Merge branch 'main' into feat/resolve-comment 2025-07-24 21:47:41 -07:00
37b9056070 sync 2025-07-24 16:38:32 -07:00
ad5cf1e18b feat: add resizable embed component │ (#1401)
- Created reusable ResizableWrapper component
- Added drag-to-resize functionality for embeds
2025-07-25 00:23:14 +01:00
32c7ecd9cf feat: set mermaid theme based on computed color scheme (#1397)
Use Mantine's `useComputedColorScheme` hook to dynamically configure mermaid's theme.
- When the computed color scheme is "light", the theme is set to "default".
- Otherwise, it is set to "dark".
2025-07-25 00:22:27 +01:00
b30bf61dc4 feat: home space list (#1400) 2025-07-25 00:21:40 +01:00
662460252f feat(EE): MFA implementation (#1381)
* feat(EE): MFA implementation for enterprise edition
- Add TOTP-based two-factor authentication
- Add backup codes support
- Add MFA enforcement at workspace level
- Add MFA setup and challenge UI pages
- Support MFA for login and password reset flows
- Add MFA validation for secure pages
* fix types
* remove unused object
* sync
* remove unused type
* sync
* refactor: rename MFA enabled field to is_enabled
* sync
2025-07-25 00:18:53 +01:00
8522844673 feat: duplicate page in same space (#1394)
* fix internal links in copies pages

* feat: duplicate page in same space

* fix children
2025-07-21 21:39:57 +01:00
f8dc9845a7 fix page tree api atom (#1391)
- The tree api atom state is not always set, which makes it impossble to create new pages since the buttons rely on it.
- this should fix it.
2025-07-21 05:02:40 +01:00
4dfed2b2af queue import attachments upload (#1353) 2025-07-19 18:00:06 +01:00
44e592763d feat: quick theme toggle and Mantine 8 upgrade (#1369)
* upgrade to mantine v8

* feat: quick theme toggle
2025-07-15 06:28:27 +01:00
90488a95b1 feat: table background color, cell header and align (#1352)
* feat: add toggle header cell button to table cell menu

Added ability to toggle header cells directly from the table cell menu. This enhancement includes:
- New toggle header cell button with IconTableRow icon
- Consistent UI/UX with existing table menu patterns
- Proper internationalization support

* fix: typo in aria-label for toggle header cell button

* feat: add table cell background color picker

- Extended TableCell and TableHeader to support backgroundColor attribute
- Created TableBackgroundColor component with 21 color options
- Integrated color picker into table cell menu using Mantine UI
- Added support for both regular cells and header cells
- Updated imports to use custom TableHeader from @docmost/editor-ext

* feat: add text alignment to table cell menu

- Created TableTextAlignment component with left, center, and right alignment options
- Integrated alignment selector into table cell menu
- Shows current alignment icon in the button
- Displays checkmark next to active alignment in dropdown

* background colors

* table background color in dark mode

* add bg color name

* rename color attribute

* increase minimum table width
2025-07-15 06:27:48 +01:00
9f39987404 fix: nested ordered-list style (#1351)
* feat: dynamic ordered-list style
* fix nested task list import
2025-07-15 02:43:59 +01:00
16ec218ba7 fix: deactivated user check 2025-07-14 10:28:42 -07:00
608783b5cf (cloud) billing copy 2025-07-14 03:56:26 -07:00
5f5f1484db throw early 2025-07-14 03:53:07 -07:00
f4082171ec feat: display user email below name in multi-member-select dropdown (#1355)
- Added email field to user items mapping
- Updated renderMultiSelectOption to show email in smaller, dimmed text
- Email only displays for user type options, not groups
2025-07-14 10:37:13 +01:00
6792a191b1 feat: Ctrl/Cmd+S: prevent 'Save As' dialog (#1272)
* init

* remove: force save

* switch from event.key to event.code by sanua356
2025-07-14 10:36:24 +01:00
e51a93221c more checks for collab auth token (#1345) 2025-07-14 10:35:03 +01:00
e856c8eb69 (cloud) fix: updates to billing (#1367)
* billing updates (cloud)

* old billing grace period
2025-07-14 10:34:18 +01:00
c2c165528b fix: seamlessly update editor collab token on expiration (#1366) 2025-07-14 07:19:06 +01:00
9fa2b9636c make sure editor is ready for editor search 2025-07-13 15:38:29 -07:00
29388636bf feat: find and replace in editor (#689)
* feat: page find and replace

* * Refactor search and replace directory

* bugfix scroll

* Fix search and replace functionality for macOS and improve UX

- Fixed cmd+f shortcut to work on macOS (using 'Mod' key instead of 'Control')
- Added search functionality to title editor
- Fixed "Not found" message showing when search term is empty
- Fixed tooltip error when clicking replace button
- Changed replace button from icon to text for consistency
- Reduced width of search input fields for better UI
- Fixed result index after replace operation to prevent out-of-bounds error
- Added missing translation strings for search and replace dialog
- Updated tooltip to show platform-specific shortcuts (⌘F on Mac, Ctrl-F on others)

* Hide replace functionality for users with view-only permissions

- Added editable prop to SearchAndReplaceDialog component
- Pass editable state from PageEditor to SearchAndReplaceDialog
- Conditionally render replace button based on edit permissions
- Hide replace input section for view-only users
- Disable Alt+R shortcut when user lacks edit permissions

* Fix search dialog not closing properly when navigating away

- Clear all state (search text, replace text) when closing dialog
- Reset replace button visibility state on close
- Clear editor search term to remove highlights
- Ensure dialog closes properly when route changes

* fix: preserve text marks (comments, etc.) when replacing text in search and replace

- Collect all marks that span the text being replaced using nodesBetween
- Apply collected marks to the replacement text to maintain formatting
- Fixes issue where comment marks were being removed during text replacement

* ignore type error

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-07-10 04:40:07 +01:00
f80004817c sync 2025-07-08 16:05:34 -07:00
ac79a185de fix ctrl-a for codeblocks (#1336) 2025-07-08 22:13:21 +01:00
27a9c0ebe4 sync 2025-07-07 14:55:09 -07:00
e36ec583ad Merge main 2025-07-03 22:28:03 -07:00
2db5a85ef0 cleanup 2025-07-03 22:10:36 -07:00
eb9cbabeba sticky comment state tabs (EE) 2025-07-03 22:03:57 -07:00
67f8bcfeca comment ui permissions 2025-07-03 20:52:22 -07:00
129aaaa375 Add resolve to comment mark in editor (EE) 2025-07-03 17:54:19 -07:00
e190945da8 feat: resolve comment (EE) 2025-07-03 14:24:09 -07:00
81ffa6f459 sync 2025-07-03 04:12:24 -07:00
5364702b69 fix: comments block on edge and older browser (#1310)
* fix: overflow on edge and older browser
2025-07-01 05:14:08 +01:00
232cea8cc9 sync 2025-06-27 03:20:01 -07:00
b9643d3584 sync 2025-06-27 03:07:51 -07:00
9f144d35fb posthog integration (cloud) (#1304) 2025-06-27 10:58:36 +01:00
e44c170873 fix editor flickers on collab reconnection (#1295)
* fix editor flickers on reconnection

* cleanup

* adjust copy
2025-06-27 10:58:18 +01:00
1be39d4353 sync 2025-06-27 02:22:11 -07:00
36d028ef4d sync 2025-06-24 05:53:59 -07:00
f5a36c60e8 feat: tiered billing (cloud) (#1294)
* feat: tiered billing (cloud)

* custom tier
2025-06-24 13:22:38 +01:00
d5b84ae0b8 Only allow changing the email if the correct password is provided (#1288)
* fix

* fix overwriting password

* finalize

* BadRequestException

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-24 09:02:55 +01:00
e775e4dd8c fix(editor): prevent text color removal from other list items when setting color in lists (#1289)
Only unset color when 'Default' is selected. This ensures setting color on one list item does not remove it from others.
2025-06-23 19:31:30 +01:00
65b01038d7 v0.21.0 2025-06-18 14:28:14 -07:00
e07cb57b01 sync 2025-06-18 14:25:40 -07:00
2b53e0a455 fix: add import size limit to static window config 2025-06-18 13:58:41 -07:00
b9b3406b28 Fix: Prevent premature focus change in TitleEditor when pressing Enter during IME composition (#730)
* fix: Prevents key events during text composition

Stops handling title key events when composing text,
ensuring proper input behavior during IME use.

* Refines IME composition event checks

Separates IME composition control from shift key logic and adds a Safari-specific keyCode check to prevent premature focus shifts during IME input.
2025-06-18 21:33:35 +01:00
728cac0a34 fix word counter (#1269) 2025-06-18 21:32:11 +01:00
d35e16010b handle empty invitation 2025-06-18 13:10:32 -07:00
15791d4e59 sync 2025-06-18 12:50:43 -07:00
3318e13225 fix: use JWT expiry time for cookie duration (#1268)
* Set default jwt expiry to 90 days.
2025-06-18 20:50:11 +01:00
080900610d cleanup 2025-06-17 16:14:06 -07:00
d1dc6977ab feat: edit mode preference (#666)
* lock/unlock pages

* remove using isLocked column - add default page edit state preference

* * Move state management to editors (avoids flickers on edit mode switch)
* Rename variables
* Add strings to translation file
* Memoize components in page component
* Fix title editor sending update request on editable state change

* fixed errors merging main

* Fix embed view in read-only mode

* remove unused line

* sync

* fix responsiveness on mobile

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-18 00:11:47 +01:00
5f62448894 less create workspace form fields in cloud (#1265)
* sync

* less signup form fields in cloud

* min length
2025-06-17 23:56:07 +01:00
44445fbf46 fix: enforce SSO in invitation signups (#1258) 2025-06-15 20:25:15 +01:00
1c674efddd fix: revert tiptap version (#1255) 2025-06-13 21:38:49 +01:00
ccf7e34e99 feat: ukrainian language support (#1250) 2025-06-11 23:31:45 +01:00
f39d48d6ee New Crowdin updates (#1063)
* New translations translation.json
2025-06-11 23:21:01 +01:00
f584ea84b0 chore: upgrade packages (#1242)
* upgrade tiptap editor extensions

* upgrade packages

* fix type issue
2025-06-11 23:18:39 +01:00
bc0c4d6258 fix: make link popup work on safari (#1243)
* fix: make link popup work on safari

* fix: second iteration

* chore: cleanup

* chore: format

* chore: undo unused stuff
2025-06-11 23:09:59 +01:00
d8da307a61 feat: enhance excalidraw (#1240)
* WIP

* use next excalidraw version

* support local persistence for excalidraw library.

Co-authored-by: Drauggy <n.fomenko@safe-tech.ru>

---------

Co-authored-by: Drauggy <n.fomenko@safe-tech.ru>
2025-06-09 23:25:36 +01:00
50b3f9ddd9 generic iframe embed (#1234) 2025-06-09 22:32:23 +01:00
0029f84d50 feat: toggle table header row and column (#1203)
* feat: toggle table header row and column
* switch position
2025-06-09 05:39:43 +01:00
6d024fc3de feat: bulk page imports (#1219)
* refactor imports - WIP

* Add readstream

* WIP

* fix attachmentId render

* fix attachmentId render

* turndown video tag

* feat: add stream upload support and improve file handling

- Add stream upload functionality to storage drivers\n- Improve ZIP file extraction with better encoding handling\n- Fix attachment ID rendering issues\n- Add AWS S3 upload stream support\n- Update dependencies for better compatibility

* WIP

* notion formatter

* move embed parser to editor-ext package

* import embeds

* utility files

* cleanup

* Switch from happy-dom to cheerio
* Refine code

* WIP

* bug fixes and UI

* sync

* WIP

* sync

* keep import modal mounted

* Show modal during upload

* WIP

* WIP
2025-06-09 04:29:27 +01:00
ce1503af85 fix: sidebar list when changing workspace (#1150)
* init

* navigate in overview if current page is in deleted node

* fix: implement pagination in sidebar-pages queries

* fix: appendNodeChildren()

Preserve deeper children if they exist and remove node if deleted
2025-06-08 03:27:09 +01:00
213 changed files with 12647 additions and 2001 deletions

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.20.4",
"version": "0.21.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -15,45 +15,48 @@
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6",
"@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",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.1.3",
"@mantine/spotlight": "^8.1.3",
"@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",
"mantine-form-zod-resolver": "^1.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",
@ -77,6 +80,6 @@
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.3.2"
"vite": "^6.3.5"
}
}

View File

@ -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"
}

View File

@ -213,7 +213,18 @@
"Comment deleted successfully": "Comment deleted successfully",
"Failed to delete comment": "Failed to delete comment",
"Comment resolved successfully": "Comment resolved successfully",
"Comment re-opened successfully": "Comment re-opened successfully",
"Comment unresolved successfully": "Comment unresolved successfully",
"Failed to resolve comment": "Failed to resolve comment",
"Resolve comment": "Resolve comment",
"Unresolve comment": "Unresolve comment",
"Resolve Comment Thread": "Resolve Comment Thread",
"Unresolve Comment Thread": "Unresolve Comment Thread",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved",
"No active comments.": "No active comments.",
"No resolved comments.": "No resolved comments.",
"Revoke invitation": "Revoke invitation",
"Revoke": "Revoke",
"Don't": "Don't",
@ -222,7 +233,9 @@
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
"Invite link": "Invite link",
"Copy": "Copy",
"Copy to space": "Copy to space",
"Copied": "Copied",
"Duplicate": "Duplicate",
"Select a user": "Select a user",
"Select a group": "Select a group",
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
@ -354,6 +367,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 +400,100 @@
"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",
"Page duplicated successfully": "Page duplicated 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",
"View all spaces": "View all spaces",
"Error": "Error",
"Failed to disable MFA": "Failed to disable MFA",
"Disable two-factor authentication": "Disable two-factor authentication",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
"Please enter your password to disable two-factor authentication:": "Please enter your password to disable two-factor authentication:",
"Two-factor authentication has been enabled": "Two-factor authentication has been enabled",
"Two-factor authentication has been disabled": "Two-factor authentication has been disabled",
"2-step verification": "2-step verification",
"Protect your account with an additional verification layer when signing in.": "Protect your account with an additional verification layer when signing in.",
"Two-factor authentication is active on your account.": "Two-factor authentication is active on your account.",
"Add 2FA method": "Add 2FA method",
"Backup codes": "Backup codes",
"Disable": "Disable",
"Invalid verification code": "Invalid verification code",
"New backup codes have been generated": "New backup codes have been generated",
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
"About backup codes": "About backup codes",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "You can regenerate new backup codes at any time. This will invalidate all existing codes.",
"Confirm password": "Confirm password",
"Generate new backup codes": "Generate new backup codes",
"Save your new backup codes": "Save your new backup codes",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
"Your new backup codes": "Your new backup codes",
"I've saved my backup codes": "I've saved my backup codes",
"Failed to setup MFA": "Failed to setup MFA",
"Setup & Verify": "Setup & Verify",
"Add to authenticator": "Add to authenticator",
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
"Can't scan the code?": "Can't scan the code?",
"Enter this code manually in your authenticator app:": "Enter this code manually in your authenticator app:",
"2. Enter the 6-digit code from your authenticator": "2. Enter the 6-digit code from your authenticator",
"Verify and enable": "Verify and enable",
"Failed to generate QR code. Please try again.": "Failed to generate QR code. Please try again.",
"Backup": "Backup",
"Save codes": "Save codes",
"Save your backup codes": "Save your backup codes",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
"Print": "Print",
"Two-factor authentication has been set up. Please log in again.": "Two-factor authentication has been set up. Please log in again.",
"Two-Factor authentication required": "Two-factor authentication required",
"Your workspace requires two-factor authentication for all users": "Your workspace requires two-factor authentication for all users",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
"Set up two-factor authentication": "Set up two-factor authentication",
"Cancel and logout": "Cancel and logout",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Your workspace requires two-factor authentication. Please set it up to continue.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
"Password is required": "Password is required",
"Password must be at least 8 characters": "Password must be at least 8 characters",
"Please enter a 6-digit code": "Please enter a 6-digit code",
"Code must be exactly 6 digits": "Code must be exactly 6 digits",
"Enter the 6-digit code found in your authenticator app": "Enter the 6-digit code found in your authenticator app",
"Need help authenticating?": "Need help authenticating?",
"MFA QR Code": "MFA QR Code",
"Account created successfully. Please log in to set up two-factor authentication.": "Account created successfully. Please log in to set up two-factor authentication.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Password reset successful. Please log in with your new password and complete two-factor authentication.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Password reset successful. Please log in with your new password to set up two-factor authentication.",
"Password reset was successful. Please log in with your new password.": "Password reset was successful. Please log in with your new password.",
"Two-factor authentication": "Two-factor authentication",
"Use authenticator app instead": "Use authenticator app instead",
"Verify backup code": "Verify backup code",
"Use backup code": "Use backup code",
"Enter one of your backup codes": "Enter one of your backup codes",
"Backup code": "Backup code",
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
"Verify": "Verify",
"Trash": "Trash",
"Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.",
"Deleted": "Deleted",
"No pages in trash": "No pages in trash",
"Permanently delete page?": "Permanently delete page?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": "ページのコピーに成功しました"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": "Страница успешно скопирована"
}

View 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": "Сторінку успішно скопійовано"
}

View File

@ -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": "页面复制成功"
}

View File

@ -29,8 +29,12 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec
import SharedPage from "@/pages/share/shared-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from '@/pages/share/share-redirect.tsx';
import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/trash.tsx";
export default function App() {
const { t } = useTranslation();
@ -45,6 +49,11 @@ export default function App() {
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route
path={"/login/mfa/setup"}
element={<MfaSetupRequiredPage />}
/>
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
@ -58,7 +67,10 @@ export default function App() {
)}
<Route element={<ShareLayout />}>
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
<Route
path={"/share/:shareId/p/:pageSlug"}
element={<SharedPage />}
/>
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
@ -67,7 +79,9 @@ export default function App() {
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={

View File

@ -0,0 +1,24 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import { User } from "server/dist/database/types/entity.types";
interface UserInfoProps {
user: User;
size?: string;
}
export function UserInfo({ user, size }: UserInfoProps) {
return (
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user?.name}
</Text>
<Text fz="xs" c="dimmed">
{user?.email}
</Text>
</div>
</Group>
);
}

View 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>
);
}

View File

@ -27,6 +27,8 @@ export function AppHeader() {
const { isTrial, trialDaysLeft } = useTrial();
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const hideSidebar = isHomeRoute || isSpacesRoute;
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
@ -38,7 +40,7 @@ export function AppHeader() {
<>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
<Group wrap="nowrap">
{!isHomeRoute && (
{!hideSidebar && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle

View File

@ -1,5 +1,5 @@
import { Box, ScrollArea, Text } from "@mantine/core";
import CommentList from "@/features/comment/components/comment-list.tsx";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
@ -18,7 +18,7 @@ export default function Aside() {
switch (tab) {
case "comments":
component = <CommentList />;
component = <CommentListWithTabs />;
title = "Comments";
break;
case "toc":
@ -38,13 +38,17 @@ export default function Aside() {
{t(title)}
</Text>
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
{tab === "comments" ? (
<CommentListWithTabs />
) : (
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
)}
</>
)}
</Box>

View File

@ -73,13 +73,15 @@ export default function GlobalAppShell({
const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/");
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const isPageRoute = location.pathname.includes("/p/");
const hideSidebar = isHomeRoute || isSpacesRoute;
return (
<AppShell
header={{ height: 45 }}
navbar={
!isHomeRoute && {
!hideSidebar && {
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
@ -100,7 +102,7 @@ export default function GlobalAppShell({
<AppShell.Header px="md" className={classes.header}>
<AppHeader />
</AppShell.Header>
{!isHomeRoute && (
{!hideSidebar && (
<AppShell.Navbar
className={classes.navbar}
withBorder={false}

View File

@ -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>
);
}

View File

@ -1,9 +1,21 @@
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
import {
Group,
Menu,
UnstyledButton,
Text,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrightnessFilled,
IconBrush,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDeviceDesktop,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
@ -19,6 +31,7 @@ export default function TopMenu() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const user = currentUser?.user;
const workspace = currentUser?.workspace;
@ -75,7 +88,7 @@ export default function TopMenu() {
name={user.name}
/>
<div style={{width: 190}}>
<div style={{ width: 190 }}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
@ -101,6 +114,44 @@ export default function TopMenu() {
{t("My preferences")}
</Menu.Item>
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
onClick={() => setColorScheme("light")}
leftSection={<IconSun size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
>
{t("Light")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>

View File

@ -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,59 @@ 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()} /{" "}
{billing.interval}
</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()} / {billing.interval}
</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>
);

View File

@ -2,24 +2,32 @@ import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
Alert,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const workspace = useAtomValue(workspaceAtom);
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null,
);
const handleCheckout = async (priceId: string) => {
try {
@ -32,84 +40,194 @@ export default function BillingPlans() {
}
};
// TODO: remove by July 30.
// Check if workspace was created between June 28 and July 14, 2025
const showTieredPricingNotice = (() => {
if (!workspace?.createdAt) return false;
const createdDate = new Date(workspace.createdAt);
const startDate = new Date('2025-06-20');
const endDate = new Date('2025-07-14');
return createdDate >= startDate && createdDate <= endDate;
})();
if (!plans || plans.length === 0) {
return null;
}
// Check if any plan is tiered
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
// Set initial tier value if not set and we have tiered plans
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
return null;
}
// For tiered plans, ensure we have a selected tier
if (hasTieredPlans && !selectedTierValue) {
return null;
}
const selectData = firstTieredPlan?.pricingTiers
?.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstTieredPlan.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">
{/* Tiered pricing notice for eligible workspaces */}
{showTieredPricingNotice && !hasTieredPlans && (
<Alert
icon={<IconInfoCircle size={16} />}
title="Want the old tiered pricing?"
color="blue"
mb="lg"
>
Contact support to switch back to our tiered pricing model.
</Alert>
)}
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" },
]}
{/* Controls Section */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
{hasTieredPlans && (
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
)}
<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) => {
let price;
let displayPrice;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
// Tiered billing logic
const planSelectedTier =
plan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || plan.pricingTiers[0];
price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
} else {
// Per-unit billing logic
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
price = isAnnual ? yearlyPrice : monthlyPrice;
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
}
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">
${displayPrice}
</Title>
<Text size="lg" c="dimmed">
{plan.billingScheme === 'per_unit'
? `per user/month`
: `per month`}
</Text>
</Group>
<Text size="sm" c="dimmed">
{isAnnual ? "Billed annually" : "Billed monthly"}
</Text>
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
<Text size="md" fw={500}>
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
</Text>
)}
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</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>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,67 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
interface ResolveCommentProps {
editor: Editor;
commentId: string;
pageId: string;
resolvedAt?: Date;
}
function ResolveComment({
editor,
commentId,
pageId,
resolvedAt,
}: ResolveCommentProps) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? "green" : "gray";
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({
commentId,
pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
//
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
};
return (
<Tooltip
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
position="top"
>
<ActionIcon
onClick={handleResolveToggle}
variant="subtle"
color={isResolved ? "green" : "gray"}
size="sm"
loading={resolveCommentMutation.isPending}
disabled={resolveCommentMutation.isPending}
>
{isResolved ? (
<IconCircleCheckFilled size={18} />
) : (
<IconCircleCheck size={18} />
)}
</ActionIcon>
</Tooltip>
);
}
export default ResolveComment;

View File

@ -0,0 +1,87 @@
import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
if (!old || !old.items) return old;
const updatedItems = old.items.map((comment) =>
comment.id === variables.commentId
? {
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? 'optimistic-user' : null,
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
}
: comment,
);
return {
...old,
items: updatedItems,
};
});
return { previousComments };
},
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
}
notifications.show({
message: t("Failed to resolve comment"),
color: "red",
});
},
onSuccess: (data: IComment, variables) => {
const pageId = data.pageId;
const currentComments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === variables.commentId
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
: comment,
);
queryClient.setQueryData(RQ_KEY(pageId), {
...currentComments,
items: updatedComments,
});
}
emit({
operation: "resolveComment",
pageId: pageId,
commentId: variables.commentId,
resolved: variables.resolved,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
});
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully")
});
},
});
}

View 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;
}

View File

@ -0,0 +1,81 @@
import React from "react";
import {
TextInput,
Button,
Stack,
Text,
Alert,
} from "@mantine/core";
import { IconKey, IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface MfaBackupCodeInputProps {
value: string;
onChange: (value: string) => void;
error?: string;
onSubmit: () => void;
onCancel: () => void;
isLoading?: boolean;
}
export function MfaBackupCodeInput({
value,
onChange,
error,
onSubmit,
onCancel,
isLoading,
}: MfaBackupCodeInputProps) {
const { t } = useTranslation();
return (
<Stack>
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
<Text size="sm">
{t(
"Enter one of your backup codes. Each backup code can only be used once.",
)}
</Text>
</Alert>
<TextInput
label={t("Backup code")}
placeholder="XXXXXXXX"
value={value}
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error}
autoFocus
maxLength={8}
styles={{
input: {
fontFamily: "monospace",
letterSpacing: "0.1em",
fontSize: "1rem",
},
}}
/>
<Stack>
<Button
fullWidth
size="md"
loading={isLoading}
onClick={onSubmit}
leftSection={<IconKey size={18} />}
>
{t("Verify backup code")}
</Button>
<Button
fullWidth
variant="subtle"
color="gray"
onClick={onCancel}
disabled={isLoading}
>
{t("Use authenticator app instead")}
</Button>
</Stack>
</Stack>
);
}

View File

@ -0,0 +1,193 @@
import React, { useState } from "react";
import {
Modal,
Stack,
Text,
Button,
Paper,
Group,
List,
Code,
CopyButton,
Alert,
PasswordInput,
} from "@mantine/core";
import {
IconRefresh,
IconCopy,
IconCheck,
IconAlertCircle,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
interface MfaBackupCodesModalProps {
opened: boolean;
onClose: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaBackupCodesModal({
opened,
onClose,
}: MfaBackupCodesModalProps) {
const { t } = useTranslation();
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showNewCodes, setShowNewCodes] = useState(false);
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
confirmPassword: "",
},
});
const regenerateMutation = useMutation({
mutationFn: (data: { confirmPassword: string }) =>
regenerateBackupCodes(data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes);
setShowNewCodes(true);
form.reset();
notifications.show({
title: t("Success"),
message: t("New backup codes have been generated"),
});
},
onError: (error: any) => {
notifications.show({
title: t("Error"),
message:
error.response?.data?.message ||
t("Failed to regenerate backup codes"),
color: "red",
});
},
});
const handleRegenerate = (values: { confirmPassword: string }) => {
regenerateMutation.mutate(values);
};
const handleClose = () => {
setShowNewCodes(false);
setBackupCodes([]);
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Backup codes")}
size="md"
>
<Stack gap="md">
{!showNewCodes ? (
<form onSubmit={form.onSubmit(handleRegenerate)}>
<Stack gap="md">
<Alert
icon={<IconAlertCircle size={20} />}
title={t("About backup codes")}
color="blue"
variant="light"
>
<Text size="sm">
{t(
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
)}
</Text>
</Alert>
<Text size="sm">
{t(
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
)}
</Text>
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
/>
<Button
type="submit"
fullWidth
loading={regenerateMutation.isPending}
leftSection={<IconRefresh size={18} />}
>
{t("Generate new backup codes")}
</Button>
</Stack>
</form>
) : (
<>
<Alert
icon={<IconAlertCircle size={20} />}
title={t("Save your new backup codes")}
color="yellow"
>
<Text size="sm">
{t(
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
)}
</Text>
</Alert>
<Paper p="md" withBorder>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t("Your new backup codes")}
</Text>
<CopyButton value={backupCodes.join("\n")}>
{({ copied, copy }) => (
<Button
size="xs"
variant="subtle"
onClick={copy}
leftSection={
copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)
}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
</Group>
<List size="sm" spacing="xs">
{backupCodes.map((code, index) => (
<List.Item key={index}>
<Code>{code}</Code>
</List.Item>
))}
</List>
</Paper>
<Button
fullWidth
onClick={handleClose}
leftSection={<IconCheck size={18} />}
>
{t("I've saved my backup codes")}
</Button>
</>
)}
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,12 @@
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.paper {
width: 100%;
box-shadow: var(--mantine-shadow-lg);
}

View File

@ -0,0 +1,160 @@
import React, { useState } from "react";
import {
Container,
Title,
Text,
PinInput,
Button,
Stack,
Anchor,
Paper,
Center,
ThemeIcon,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import classes from "./mfa-challenge.module.css";
import { verifyMfa } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
const formSchema = z.object({
code: z
.string()
.refine(
(val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
{
message: "Enter a 6-digit code or 8-character backup code",
},
),
});
type MfaChallengeFormValues = z.infer<typeof formSchema>;
export function MfaChallenge() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [useBackupCode, setUseBackupCode] = useState(false);
const form = useForm<MfaChallengeFormValues>({
validate: zodResolver(formSchema),
initialValues: {
code: "",
},
});
const handleSubmit = async (values: MfaChallengeFormValues) => {
setIsLoading(true);
try {
await verifyMfa(values.code);
navigate(APP_ROUTE.HOME);
} catch (error: any) {
setIsLoading(false);
notifications.show({
message:
error.response?.data?.message || t("Invalid verification code"),
color: "red",
});
form.setFieldValue("code", "");
}
};
return (
<Container size={420} className={classes.container}>
<Paper radius="lg" p={40} className={classes.paper}>
<Stack align="center" gap="xl">
<Center>
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
<IconDeviceMobile size={40} stroke={1.5} />
</ThemeIcon>
</Center>
<Stack align="center" gap="xs">
<Title order={2} ta="center" fw={600}>
{t("Two-factor authentication")}
</Title>
<Text size="sm" c="dimmed" ta="center">
{useBackupCode
? t("Enter one of your backup codes")
: t("Enter the 6-digit code found in your authenticator app")}
</Text>
</Stack>
{!useBackupCode ? (
<form
onSubmit={form.onSubmit(handleSubmit)}
style={{ width: "100%" }}
>
<Stack gap="lg">
<Center>
<PinInput
length={6}
type="number"
autoFocus
oneTimeCode
{...form.getInputProps("code")}
error={!!form.errors.code}
styles={{
input: {
fontSize: "1.2rem",
textAlign: "center",
},
}}
/>
</Center>
{form.errors.code && (
<Text c="red" size="sm" ta="center">
{form.errors.code}
</Text>
)}
<Button
type="submit"
fullWidth
size="md"
loading={isLoading}
leftSection={<IconLock size={18} />}
>
{t("Verify")}
</Button>
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => {
setUseBackupCode(true);
form.setFieldValue("code", "");
form.clearErrors();
}}
>
{t("Use backup code")}
</Anchor>
</Stack>
</form>
) : (
<MfaBackupCodeInput
value={form.values.code}
onChange={(value) => form.setFieldValue("code", value)}
error={form.errors.code?.toString()}
onSubmit={() => handleSubmit(form.values)}
onCancel={() => {
setUseBackupCode(false);
form.setFieldValue("code", "");
form.clearErrors();
}}
isLoading={isLoading}
/>
)}
</Stack>
</Paper>
</Container>
);
}

View File

@ -0,0 +1,124 @@
import React from "react";
import {
Modal,
Stack,
Text,
Button,
PasswordInput,
Alert,
} from "@mantine/core";
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { disableMfa } from "@/ee/mfa";
interface MfaDisableModalProps {
opened: boolean;
onClose: () => void;
onComplete: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({
opened,
onClose,
onComplete,
}: MfaDisableModalProps) {
const { t } = useTranslation();
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
confirmPassword: "",
},
});
const disableMutation = useMutation({
mutationFn: disableMfa,
onSuccess: () => {
onComplete();
},
onError: (error: any) => {
notifications.show({
title: t("Error"),
message: error.response?.data?.message || t("Failed to disable MFA"),
color: "red",
});
},
});
const handleSubmit = async (values: { confirmPassword: string }) => {
await disableMutation.mutateAsync(values);
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Disable two-factor authentication")}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={20} />}
title={t("Warning")}
color="red"
variant="light"
>
<Text size="sm">
{t(
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
)}
</Text>
</Alert>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
/>
<Stack gap="sm">
<Button
type="submit"
fullWidth
color="red"
loading={disableMutation.isPending}
leftSection={<IconShieldOff size={18} />}
>
{t("Disable two-factor authentication")}
</Button>
<Button
fullWidth
variant="default"
onClick={handleClose}
disabled={disableMutation.isPending}
>
{t("Cancel")}
</Button>
</Stack>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,112 @@
import React, { useState } from "react";
import { Group, Text, Button } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa";
export function MfaSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"],
queryFn: getMfaStatus,
});
if (isLoading) {
return null;
}
// Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true;
const handleSetupComplete = () => {
setSetupModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
notifications.show({
title: t("Success"),
message: t("Two-factor authentication has been enabled"),
});
};
const handleDisableComplete = () => {
setDisableModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
notifications.show({
title: t("Success"),
message: t("Two-factor authentication has been disabled"),
color: "blue",
});
};
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div style={{ minWidth: 0, flex: 1 }}>
<Text size="md">{t("2-step verification")}</Text>
<Text size="sm" c="dimmed">
{!isMfaEnabled
? t(
"Protect your account with an additional verification layer when signing in.",
)
: t("Two-factor authentication is active on your account.")}
</Text>
</div>
{!isMfaEnabled ? (
<Button
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Add 2FA method")}
</Button>
) : (
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
size="sm"
onClick={() => setBackupCodesModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
</Button>
<Button
variant="default"
size="sm"
color="red"
onClick={() => setDisableModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</Group>
<MfaSetupModal
opened={setupModalOpen}
onClose={() => setSetupModalOpen(false)}
onComplete={handleSetupComplete}
/>
<MfaDisableModal
opened={disableModalOpen}
onClose={() => setDisableModalOpen(false)}
onComplete={handleDisableComplete}
/>
<MfaBackupCodesModal
opened={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
/>
</>
);
}

View File

@ -0,0 +1,347 @@
import React, { useState } from "react";
import {
Modal,
Stack,
Text,
Button,
Group,
Stepper,
Center,
Image,
PinInput,
Alert,
List,
CopyButton,
ActionIcon,
Tooltip,
Paper,
Code,
Loader,
Collapse,
UnstyledButton,
} from "@mantine/core";
import {
IconQrcode,
IconShieldCheck,
IconKey,
IconCopy,
IconCheck,
IconAlertCircle,
IconChevronDown,
IconChevronRight,
IconPrinter,
} from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { setupMfa, enableMfa } from "@/ee/mfa";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
interface MfaSetupModalProps {
opened: boolean;
onClose?: () => void;
onComplete: () => void;
isRequired?: boolean;
}
interface SetupData {
secret: string;
qrCode: string;
manualKey: string;
}
const formSchema = z.object({
verificationCode: z
.string()
.length(6, { message: "Please enter a 6-digit code" }),
});
export function MfaSetupModal({
opened,
onClose,
onComplete,
isRequired = false,
}: MfaSetupModalProps) {
const { t } = useTranslation();
const [active, setActive] = useState(0);
const [setupData, setSetupData] = useState<SetupData | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [manualEntryOpen, setManualEntryOpen] = useState(false);
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
verificationCode: "",
},
});
const setupMutation = useMutation({
mutationFn: () => setupMfa({ method: "totp" }),
onSuccess: (data) => {
setSetupData(data);
},
onError: (error: any) => {
notifications.show({
title: t("Error"),
message: error.response?.data?.message || t("Failed to setup MFA"),
color: "red",
});
},
});
// Generate QR code when modal opens
React.useEffect(() => {
if (opened && !setupData && !setupMutation.isPending) {
setupMutation.mutate();
}
}, [opened]);
const enableMutation = useMutation({
mutationFn: (verificationCode: string) =>
enableMfa({
secret: setupData!.secret,
verificationCode,
}),
onSuccess: (data) => {
setBackupCodes(data.backupCodes);
setActive(1); // Move to backup codes step
},
onError: (error: any) => {
notifications.show({
title: t("Error"),
message:
error.response?.data?.message || t("Invalid verification code"),
color: "red",
});
form.setFieldValue("verificationCode", "");
},
});
const handleClose = () => {
if (active === 1 && backupCodes.length > 0) {
onComplete();
}
onClose();
// Reset state
setTimeout(() => {
setActive(0);
setSetupData(null);
setBackupCodes([]);
setManualEntryOpen(false);
form.reset();
}, 200);
};
const handleVerify = async (values: { verificationCode: string }) => {
await enableMutation.mutateAsync(values.verificationCode);
};
const handlePrintBackupCodes = () => {
window.print();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Set up two-factor authentication")}
size="md"
>
<Stepper active={active} size="sm">
<Stepper.Step
label={t("Setup & Verify")}
description={t("Add to authenticator")}
icon={<IconQrcode size={18} />}
>
<form onSubmit={form.onSubmit(handleVerify)}>
<Stack gap="md" mt="xl">
{setupMutation.isPending ? (
<Center py="xl">
<Loader size="lg" />
</Center>
) : setupData ? (
<>
<Text size="sm">
{t("1. Scan this QR code with your authenticator app")}
</Text>
<Center>
<Paper p="md" withBorder>
<Image
src={setupData.qrCode}
alt="MFA QR Code"
width={200}
height={200}
/>
</Paper>
</Center>
<UnstyledButton
onClick={() => setManualEntryOpen(!manualEntryOpen)}
>
<Group gap="xs">
{manualEntryOpen ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
<Text size="sm" c="dimmed">
{t("Can't scan the code?")}
</Text>
</Group>
</UnstyledButton>
<Collapse in={manualEntryOpen}>
<Alert
icon={<IconAlertCircle size={20} />}
color="gray"
variant="light"
>
<Text size="sm" mb="sm">
{t(
"Enter this code manually in your authenticator app:",
)}
</Text>
<Group gap="xs">
<Code block>{setupData.manualKey}</Code>
<CopyButton value={setupData.manualKey}>
{({ copied, copy }) => (
<Tooltip label={copied ? t("Copied") : t("Copy")}>
<ActionIcon
color={copied ? "green" : "gray"}
onClick={copy}
>
{copied ? (
<IconCheck size={16} />
) : (
<IconCopy size={16} />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Alert>
</Collapse>
<Text size="sm" mt="md">
{t("2. Enter the 6-digit code from your authenticator")}
</Text>
<Stack align="center">
<PinInput
length={6}
type="number"
autoFocus
oneTimeCode
{...form.getInputProps("verificationCode")}
styles={{
input: {
fontSize: "1.2rem",
textAlign: "center",
},
}}
/>
{form.errors.verificationCode && (
<Text c="red" size="sm">
{form.errors.verificationCode}
</Text>
)}
</Stack>
<Button
type="submit"
fullWidth
loading={enableMutation.isPending}
leftSection={<IconShieldCheck size={18} />}
>
{t("Verify and enable")}
</Button>
</>
) : (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Failed to generate QR code. Please try again.")}
</Text>
</Center>
)}
</Stack>
</form>
</Stepper.Step>
<Stepper.Step
label={t("Backup")}
description={t("Save codes")}
icon={<IconKey size={18} />}
>
<Stack gap="md" mt="xl">
<Alert
icon={<IconAlertCircle size={20} />}
title={t("Save your backup codes")}
color="yellow"
>
<Text size="sm">
{t(
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
)}
</Text>
</Alert>
<Paper p="md" withBorder>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t("Backup codes")}
</Text>
<Group gap="xs" wrap="nowrap">
<CopyButton value={backupCodes.join("\n")}>
{({ copied, copy }) => (
<Button
size="xs"
variant="subtle"
onClick={copy}
leftSection={
copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)
}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
<Button
size="xs"
variant="subtle"
onClick={handlePrintBackupCodes}
leftSection={<IconPrinter size={14} />}
>
{t("Print")}
</Button>
</Group>
</Group>
<List size="sm" spacing="xs">
{backupCodes.map((code, index) => (
<List.Item key={index}>
<Code>{code}</Code>
</List.Item>
))}
</List>
</Paper>
<Button
fullWidth
onClick={handleClose}
leftSection={<IconCheck size={18} />}
>
{t("I've saved my backup codes")}
</Button>
</Stack>
</Stepper.Step>
</Stepper>
</Modal>
);
}

View File

@ -0,0 +1,48 @@
import React from "react";
import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export default function MfaSetupRequired() {
const { t } = useTranslation();
const navigate = useNavigate();
const handleSetupComplete = () => {
navigate(APP_ROUTE.HOME);
};
return (
<Container size="sm" py="xl">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Stack>
<Title order={2} ta="center">
{t("Two-factor authentication required")}
</Title>
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
<Text size="sm">
{t(
"Your workspace requires two-factor authentication. Please set it up to continue.",
)}
</Text>
</Alert>
<Text c="dimmed" size="sm" ta="center">
{t(
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
)}
</Text>
<MfaSetupModal
opened={true}
onComplete={handleSetupComplete}
isRequired={true}
/>
</Stack>
</Paper>
</Container>
);
}

View File

@ -0,0 +1,31 @@
.qrCodeContainer {
background-color: white;
padding: 1rem;
border-radius: var(--mantine-radius-md);
display: inline-block;
}
.backupCodesList {
font-family: var(--mantine-font-family-monospace);
background-color: var(--mantine-color-gray-0);
padding: 1rem;
border-radius: var(--mantine-radius-md);
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.codeItem {
padding: 0.25rem 0;
font-size: 0.875rem;
}
.setupStep {
min-height: 400px;
}
.verificationInput {
max-width: 320px;
margin: 0 auto;
}

View File

@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import { validateMfaAccess } from "@/ee/mfa";
export function useMfaPageProtection() {
const navigate = useNavigate();
const location = useLocation();
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(false);
useEffect(() => {
const checkAccess = async () => {
const result = await validateMfaAccess();
if (!result.valid) {
navigate(APP_ROUTE.AUTH.LOGIN);
return;
}
// Check if user is on the correct page based on their MFA state
const isOnChallengePage =
location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
const isOnSetupPage =
location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
if (result.requiresMfaSetup && !isOnSetupPage) {
// User needs to set up MFA but is on challenge page
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else if (
!result.requiresMfaSetup &&
result.userHasMfa &&
!isOnChallengePage
) {
// User has MFA and should be on challenge page
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (!result.isTransferToken) {
// User has a regular auth token, shouldn't be on MFA pages
navigate(APP_ROUTE.HOME);
} else {
setIsValid(true);
}
setIsValidating(false);
};
checkAccess();
}, [navigate, location.pathname]);
return { isValidating, isValid };
}

View File

@ -0,0 +1,19 @@
// Components
export { MfaChallenge } from "./components/mfa-challenge";
export { MfaSettings } from "./components/mfa-settings";
export { MfaSetupModal } from "./components/mfa-setup-modal";
export { MfaDisableModal } from "./components/mfa-disable-modal";
export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
// Pages
export { MfaChallengePage } from "./pages/mfa-challenge-page";
export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
// Services
export * from "./services/mfa-service";
// Types
export * from "./types/mfa.types";
// Hooks
export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";

View File

@ -0,0 +1,13 @@
import React from "react";
import { MfaChallenge } from "@/ee/mfa";
import { useMfaPageProtection } from "@/ee/mfa";
export function MfaChallengePage() {
const { isValid } = useMfaPageProtection();
if (!isValid) {
return null;
}
return <MfaChallenge />;
}

View File

@ -0,0 +1,113 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Container,
Title,
Text,
Button,
Stack,
Paper,
Alert,
Center,
ThemeIcon,
} from "@mantine/core";
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import APP_ROUTE from "@/lib/app-route";
import { MfaSetupModal } from "@/ee/mfa";
import classes from "@/features/auth/components/auth.module.css";
import { notifications } from "@mantine/notifications";
import { useMfaPageProtection } from "@/ee/mfa";
export function MfaSetupRequiredPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [setupModalOpen, setSetupModalOpen] = useState(false);
const { isValid } = useMfaPageProtection();
const handleSetupComplete = async () => {
setSetupModalOpen(false);
notifications.show({
title: t("Success"),
message: t(
"Two-factor authentication has been set up. Please log in again.",
),
});
navigate(APP_ROUTE.AUTH.LOGIN);
};
const handleLogout = () => {
navigate(APP_ROUTE.AUTH.LOGIN);
};
if (!isValid) {
return null;
}
return (
<Container size={480} className={classes.container}>
<Paper radius="lg" p={40}>
<Stack align="center" gap="xl">
<Center>
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
<IconShieldCheck size={40} stroke={1.5} />
</ThemeIcon>
</Center>
<Stack align="center" gap="xs">
<Title order={2} ta="center" fw={600}>
{t("Two-factor authentication required")}
</Title>
<Text size="md" c="dimmed" ta="center">
{t(
"Your workspace requires two-factor authentication for all users",
)}
</Text>
</Stack>
<Alert
icon={<IconAlertCircle size={20} />}
color="blue"
variant="light"
w="100%"
>
<Text size="sm">
{t(
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
)}
</Text>
</Alert>
<Stack w="100%" gap="sm">
<Button
fullWidth
size="md"
onClick={() => setSetupModalOpen(true)}
leftSection={<IconShieldCheck size={18} />}
>
{t("Set up two-factor authentication")}
</Button>
<Button
fullWidth
variant="subtle"
color="gray"
onClick={handleLogout}
>
{t("Cancel and logout")}
</Button>
</Stack>
</Stack>
</Paper>
<MfaSetupModal
opened={setupModalOpen}
onClose={() => setSetupModalOpen(false)}
onComplete={handleSetupComplete}
isRequired={true}
/>
</Container>
);
}

View File

@ -0,0 +1,61 @@
import api from "@/lib/api-client";
import {
MfaBackupCodesResponse,
MfaDisableRequest,
MfaEnableRequest,
MfaEnableResponse,
MfaSetupRequest,
MfaSetupResponse,
MfaStatusResponse,
MfaAccessValidationResponse,
} from "@/ee/mfa";
export async function getMfaStatus(): Promise<MfaStatusResponse> {
const req = await api.post("/mfa/status");
return req.data;
}
export async function setupMfa(
data: MfaSetupRequest,
): Promise<MfaSetupResponse> {
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
return req.data;
}
export async function enableMfa(
data: MfaEnableRequest,
): Promise<MfaEnableResponse> {
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
return req.data;
}
export async function disableMfa(
data: MfaDisableRequest,
): Promise<{ success: boolean }> {
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
return req.data;
}
export async function regenerateBackupCodes(data: {
confirmPassword: string;
}): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes",
data,
);
return req.data;
}
export async function verifyMfa(code: string): Promise<any> {
const req = await api.post("/mfa/verify", { code });
return req.data;
}
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
try {
const res = await api.post("/mfa/validate-access");
return res.data;
} catch {
return { valid: false };
}
}

View File

@ -0,0 +1,62 @@
export interface MfaMethod {
type: 'totp' | 'email';
isEnabled: boolean;
}
export interface MfaSettings {
isEnabled: boolean;
methods: MfaMethod[];
backupCodesCount: number;
lastUpdated?: string;
}
export interface MfaSetupState {
method: 'totp' | 'email';
secret?: string;
qrCode?: string;
manualEntry?: string;
backupCodes?: string[];
}
export interface MfaStatusResponse {
isEnabled?: boolean;
method?: string | null;
backupCodesCount?: number;
}
export interface MfaSetupRequest {
method: 'totp';
}
export interface MfaSetupResponse {
method: string;
qrCode: string;
secret: string;
manualKey: string;
}
export interface MfaEnableRequest {
secret: string;
verificationCode: string;
}
export interface MfaEnableResponse {
success: boolean;
backupCodes: string[];
}
export interface MfaDisableRequest {
confirmPassword: string;
}
export interface MfaBackupCodesResponse {
backupCodes: string[];
}
export interface MfaAccessValidationResponse {
valid: boolean;
isTransferToken?: boolean;
requiresMfaSetup?: boolean;
userHasMfa?: boolean;
isMfaEnforced?: boolean;
}

View File

@ -0,0 +1,66 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
export default function EnforceMfa() {
const { t } = useTranslation();
return (
<>
<Title order={4} my="sm">
MFA
</Title>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enforce two-factor authentication")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle />
</Group>
</>
);
}
interface EnforceMfaToggleProps {
size?: MantineSize;
label?: string;
}
export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceMfa);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ enforceMfa: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle MFA enforcement")}
/>
);
}

View File

@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
export default function Security() {
const { t } = useTranslation();
@ -33,6 +34,10 @@ export default function Security() {
<Divider my="lg" />
<EnforceMfa />
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>

View File

@ -1,7 +1,7 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import {
Container,
Title,
@ -11,6 +11,7 @@ import {
Box,
Stack,
} from "@mantine/core";
import { zodResolver } from "mantine-form-zod-resolver";
import { useParams, useSearchParams } from "react-router-dom";
import { IRegister } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
@ -18,6 +19,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 +73,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>
);

View File

@ -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"

View File

@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@ -39,9 +39,17 @@ export default function useAuth() {
setIsLoading(true);
try {
await login(data);
const response = await login(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
// Check if MFA is required
if (response?.userHasMfa) {
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
navigate(APP_ROUTE.HOME);
}
} catch (err) {
setIsLoading(false);
console.log(err);
@ -56,9 +64,19 @@ export default function useAuth() {
setIsLoading(true);
try {
await acceptInvitation(data);
const response = await acceptInvitation(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
if (response?.requiresLogin) {
notifications.show({
message: t(
"Account created successfully. Please log in to set up two-factor authentication.",
),
});
navigate(APP_ROUTE.AUTH.LOGIN);
} else {
navigate(APP_ROUTE.HOME);
}
} catch (err) {
setIsLoading(false);
notifications.show({
@ -100,12 +118,22 @@ export default function useAuth() {
setIsLoading(true);
try {
await passwordReset(data);
const response = await passwordReset(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
notifications.show({
message: t("Password reset was successful"),
});
if (response?.requiresLogin) {
notifications.show({
message: t(
"Password reset was successful. Please log in with your new password.",
),
});
navigate(APP_ROUTE.AUTH.LOGIN);
} else {
navigate(APP_ROUTE.HOME);
notifications.show({
message: t("Password reset was successful"),
});
}
} catch (err) {
setIsLoading(false);
notifications.show({

View File

@ -4,14 +4,16 @@ import {
ICollabToken,
IForgotPassword,
ILogin,
ILoginResponse,
IPasswordReset,
ISetupWorkspace,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export async function login(data: ILogin): Promise<void> {
await api.post<void>("/auth/login", data);
export async function login(data: ILogin): Promise<ILoginResponse> {
const response = await api.post<ILoginResponse>("/auth/login", data);
return response.data;
}
export async function logout(): Promise<void> {
@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise<void> {
await api.post<void>("/auth/forgot-password", data);
}
export async function passwordReset(data: IPasswordReset): Promise<void> {
await api.post<void>("/auth/password-reset", data);
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
const req = await api.post("/auth/password-reset", data);
return req.data;
}
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data;
}
}

View File

@ -10,7 +10,7 @@ export interface IRegister {
}
export interface ISetupWorkspace {
workspaceName: string;
workspaceName?: string;
name: string;
email: string;
password: string;
@ -36,5 +36,12 @@ export interface IVerifyUserToken {
}
export interface ICollabToken {
token: string;
token?: string;
}
export interface ILoginResponse {
userHasMfa?: boolean;
requiresMfaSetup?: boolean;
mfaToken?: string;
isMfaEnforced?: boolean;
}

View File

@ -1,4 +1,4 @@
import { Group, Text, Box } from "@mantine/core";
import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
@ -7,22 +7,34 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import ResolveComment from "@/ee/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useDeleteCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useTranslation } from "react-i18next";
interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
userSpaceRole?: string;
}
function CommentListItem({ comment, pageId }: CommentListItemProps) {
function CommentListItem({
comment,
pageId,
canComment,
userSpaceRole,
}: CommentListItemProps) {
const { t } = useTranslation();
const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@ -30,11 +42,13 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
useEffect(() => {
setContent(comment.content)
setContent(comment.content);
}, [comment]);
async function handleUpdateComment() {
@ -72,8 +86,35 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
}
}
async function handleResolveComment() {
if (!isCloudEE) return;
try {
const isResolved = comment.resolvedAt != null;
await resolveCommentMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
}
function handleCommentClick(comment: IComment) {
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
@ -106,28 +147,42 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
</Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
{/*!comment.parentCommentId && (
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
)*/}
{!comment.parentCommentId && canComment && isCloudEE && (
<ResolveComment
editor={editor}
commentId={comment.id}
pageId={comment.pageId}
resolvedAt={comment.resolvedAt}
/>
)}
{currentUser?.user?.id === comment.creatorId && (
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
)}
</div>
</Group>
<Text size="xs" fw={500} c="dimmed">
{timeAgo(comment.createdAt)}
</Text>
<Group gap="xs">
<Text size="xs" fw={500} c="dimmed">
{timeAgo(comment.createdAt)}
</Text>
</Group>
</div>
</Group>
<div>
{!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
<Box
className={classes.textSelection}
onClick={() => handleCommentClick(comment)}
>
<Text size="sm">{comment?.selection}</Text>
</Box>
)}

View File

@ -0,0 +1,318 @@
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
useCommentsQuery,
useCreateCommentMutation,
} from "@/features/comment/queries/comment-query";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
function CommentListWithTabs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
);
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
if (!comments?.items) {
return { activeComments: [], resolvedComments: [] };
}
const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null
);
const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt
);
const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt
);
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
try {
setIsLoading(true);
const commentData = {
pageId: page?.id,
parentCommentId: commentId,
content: JSON.stringify(content),
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentMutation, page?.id]
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
{!comment.resolvedAt && canComment && (
<>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role]
);
if (isCommentsLoading) {
return <></>;
}
if (isError) {
return <div>{t("Error loading comments.")}</div>;
}
const totalComments = activeComments.length + resolvedComments.length;
// If not cloud/enterprise, show simple list without tabs
if (!isCloudEE) {
if (totalComments === 0) {
return <>{t("No comments yet.")}</>;
}
return (
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "200px" }}>
{comments?.items
.filter((comment: IComment) => comment.parentCommentId === null)
.map((comment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
</Paper>
))}
</div>
</ScrollArea>
);
}
return (
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<Tabs.List justify="center">
<Tabs.Tab
value="open"
leftSection={
<Badge size="sm" variant="light" color="blue">
{activeComments.length}
</Badge>
}
>
{t("Open")}
</Tabs.Tab>
<Tabs.Tab
value="resolved"
leftSection={
<Badge size="sm" variant="light" color="green">
{resolvedComments.length}
</Badge>
}
>
{t("Resolved")}
</Tabs.Tab>
</Tabs.List>
<ScrollArea
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>
<Tabs.Panel value="open" pt="xs">
{activeComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No open comments.")}
</Text>
) : (
activeComments.map(renderComments)
)}
</Tabs.Panel>
<Tabs.Panel value="resolved" pt="xs">
{resolvedComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No resolved comments.")}
</Text>
) : (
resolvedComments.map(renderComments)
)}
</Tabs.Panel>
</div>
</ScrollArea>
</Tabs>
</div>
);
}
interface ChildCommentsProps {
comments: IPagination<IComment>;
parentId: string;
pageId: string;
canComment: boolean;
userSpaceRole?: string;
}
const ChildComments = ({
comments,
parentId,
pageId,
canComment,
userSpaceRole,
}: ChildCommentsProps) => {
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId
),
[comments.items]
);
return (
<div>
{getChildComments(parentId).map((childComment) => (
<div key={childComment.id}>
<CommentListItem
comment={childComment}
pageId={pageId}
canComment={canComment}
userSpaceRole={userSpaceRole}
/>
<MemoizedChildComments
comments={comments}
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
userSpaceRole={userSpaceRole}
/>
</div>
))}
</div>
);
};
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = useCallback(() => {
onSave(commentId, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [commentId, content, onSave]);
return (
<div ref={ref}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
export default CommentListWithTabs;

View File

@ -1,162 +0,0 @@
import React, { useState, useRef, useCallback, memo } from "react";
import { useParams } from "react-router-dom";
import { Divider, Paper } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
useCommentsQuery,
useCreateCommentMutation,
} from "@/features/comment/queries/comment-query";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
function CommentList() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
try {
setIsLoading(true);
const commentData = {
pageId: page?.id,
parentCommentId: commentId,
content: JSON.stringify(content),
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentMutation, page?.id],
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem comment={comment} pageId={page?.id} />
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
</div>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</Paper>
),
[comments, handleAddReply, isLoading],
);
if (isCommentsLoading) {
return <></>;
}
if (isError) {
return <div>{t("Error loading comments.")}</div>;
}
if (!comments || comments.items.length === 0) {
return <>{t("No comments yet.")}</>;
}
return (
<>
{comments.items
.filter((comment) => comment.parentCommentId === null)
.map(renderComments)}
</>
);
}
interface ChildCommentsProps {
comments: IPagination<IComment>;
parentId: string;
pageId: string;
}
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items],
);
return (
<div>
{getChildComments(parentId).map((childComment) => (
<div key={childComment.id}>
<CommentListItem comment={childComment} pageId={pageId} />
<MemoizedChildComments
comments={comments}
parentId={childComment.id}
pageId={pageId}
/>
</div>
))}
</div>
);
};
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = useCallback(() => {
onSave(commentId, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [commentId, content, onSave]);
return (
<div ref={ref}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
export default CommentList;

View File

@ -1,15 +1,28 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
type CommentMenuProps = {
onEditComment: () => void;
onDeleteComment: () => void;
onResolveComment?: () => void;
canEdit?: boolean;
isResolved?: boolean;
isParentComment?: boolean;
};
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
function CommentMenu({
onEditComment,
onDeleteComment,
onResolveComment,
canEdit = true,
isResolved = false,
isParentComment = false
}: CommentMenuProps) {
const { t } = useTranslation();
const isCloudEE = useIsCloudEE();
//@ts-ignore
const openDeleteModal = () =>
@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")}
</Menu.Item>
{canEdit && (
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")}
</Menu.Item>
)}
{isParentComment && (
isCloudEE ? (
<Menu.Item
onClick={onResolveComment}
leftSection={
isResolved ?
<IconCircleCheckFilled size={14} /> :
<IconCircleCheck size={14} />
}
>
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={t("Available in enterprise edition")} position="left">
<Menu.Item
disabled
leftSection={<IconCircleCheck size={14} />}
>
{t("Resolve comment")}
</Menu.Item>
</Tooltip>
)
)}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}

View File

@ -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 {

View File

@ -1,47 +0,0 @@
import { ActionIcon } from "@mantine/core";
import { IconCircleCheck } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
function ResolveComment({ commentId, pageId, resolvedAt }) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? "green" : "gray";
//@ts-ignore
const openConfirmModal = () =>
modals.openConfirmModal({
title: t("Are you sure you want to resolve this comment thread?"),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleResolveToggle,
});
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({
commentId,
resolved: !isResolved,
});
//TODO: remove comment mark
// Remove comment thread from state on resolve
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
};
return (
<ActionIcon
onClick={openConfirmModal}
variant="default"
style={{ border: "none" }}
>
<IconCircleCheck size={20} stroke={2} color={iconColor} />
</ActionIcon>
);
}
export default ResolveComment;

View File

@ -8,13 +8,11 @@ import {
createComment,
deleteComment,
getPageComments,
resolveComment,
updateComment,
} from "@/features/comment/services/comment-service";
import {
ICommentParams,
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onSuccess: (data: IComment, variables) => {
const currentComments = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as IComment[];
/*
if (currentComments) {
const updatedComments = currentComments.map((comment) =>
comment.id === variables.commentId
? { ...comment, ...data }
: comment,
);
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
}*/
notifications.show({ message: t("Comment resolved successfully") });
},
onError: (error) => {
notifications.show({
message: t("Failed to resolve comment"),
color: "red",
});
},
});
}
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query

View File

@ -16,6 +16,7 @@ export interface IComment {
editedAt?: Date;
deletedAt?: Date;
creator: IUser;
resolvedBy?: IUser;
}
export interface ICommentData {
@ -28,6 +29,7 @@ export interface ICommentData {
export interface IResolveComment {
commentId: string;
pageId: string;
resolved: boolean;
}

View File

@ -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);

View File

@ -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" }}

View File

@ -0,0 +1,96 @@
.wrapper {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
}
.resizing {
user-select: none;
cursor: ns-resize;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
background: transparent;
}
.resizeHandleBottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 24px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
touch-action: none;
-webkit-user-select: none;
user-select: none;
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.05)
);
}
&:hover {
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.1)
);
}
}
}
.wrapper:hover .resizeHandleBottom,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizeBar {
width: 50px;
height: 4px;
border-radius: 2px;
transition: background-color 0.2s ease;
@mixin light {
background-color: var(--mantine-color-gray-5);
}
@mixin dark {
background-color: var(--mantine-color-gray-6);
}
}
.resizeHandleBottom:hover .resizeBar,
.resizing .resizeBar {
@mixin light {
background-color: var(--mantine-color-gray-7);
}
@mixin dark {
background-color: var(--mantine-color-gray-4);
}
}

View File

@ -0,0 +1,112 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import classes from "./resizable-wrapper.module.css";
interface ResizableWrapperProps {
children: ReactNode;
initialHeight?: number;
minHeight?: number;
maxHeight?: number;
onResize?: (height: number) => void;
isEditable?: boolean;
className?: string;
showHandles?: "always" | "hover";
direction?: "vertical" | "horizontal" | "both";
}
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
children,
initialHeight = 480,
minHeight = 200,
maxHeight = 1200,
onResize,
isEditable = true,
className,
showHandles = "hover",
direction = "vertical",
}) => {
const [resizeParams, setResizeParams] = useState<{
initialSize: number;
initialClientY: number;
initialClientX: number;
} | null>(null);
const [currentHeight, setCurrentHeight] = useState(initialHeight);
const [isHovered, setIsHovered] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!resizeParams) return;
const handleMouseMove = (e: MouseEvent) => {
if (!wrapperRef.current) return;
if (direction === "vertical" || direction === "both") {
const deltaY = e.clientY - resizeParams.initialClientY;
const newHeight = Math.min(
Math.max(resizeParams.initialSize + deltaY, minHeight),
maxHeight
);
setCurrentHeight(newHeight);
wrapperRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => {
setResizeParams(null);
if (onResize && currentHeight !== initialHeight) {
onResize(currentHeight);
}
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizeParams({
initialSize: currentHeight,
initialClientY: e.clientY,
initialClientX: e.clientX,
});
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
}, [currentHeight]);
const shouldShowHandles =
isEditable &&
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
return (
<div
ref={wrapperRef}
className={clsx(classes.wrapper, className, {
[classes.resizing]: !!resizeParams,
})}
style={{ height: currentHeight }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{!!resizeParams && <div className={classes.overlay} />}
{shouldShowHandles && direction === "vertical" && (
<div
className={classes.resizeHandleBottom}
onMouseDown={handleResizeStart}
>
<div className={classes.resizeBar} />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,16 @@
.embedWrapper {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.embedIframe {
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
}

View File

@ -1,9 +1,8 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import React, { useMemo, useCallback } from "react";
import clsx from "clsx";
import {
ActionIcon,
AspectRatio,
Button,
Card,
FocusTrap,
@ -14,14 +13,18 @@ import {
} from "@mantine/core";
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 { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import {
getEmbedProviderById,
getEmbedUrlAndProvider,
sanitizeUrl,
} from "@docmost/editor-ext";
import { ResizableWrapper } from "../common/resizable-wrapper";
import classes from "./embed-view.module.css";
const schema = z.object({
url: z
@ -32,8 +35,8 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes } = props;
const { src, provider } = node.attrs;
const { node, selected, updateAttributes, editor } = props;
const { src, provider, height: nodeHeight } = node.attrs;
const embedUrl = useMemo(() => {
if (src) {
@ -49,11 +52,26 @@ export default function EmbedView(props: NodeViewProps) {
validate: zodResolver(schema),
});
const handleResize = useCallback(
(newHeight: number) => {
updateAttributes({ height: newHeight });
},
[updateAttributes],
);
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
return;
}
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
updateAttributes({ src: sanitizeUrl(data.url) });
return;
}
if (embedProvider.regex.test(data.url)) {
updateAttributes({ src: data.url });
updateAttributes({ src: sanitizeUrl(data.url) });
} else {
notifications.show({
message: t("Invalid {{provider}} embed link", {
@ -69,19 +87,33 @@ export default function EmbedView(props: NodeViewProps) {
return (
<NodeViewWrapper>
{embedUrl ? (
<>
<AspectRatio ratio={16 / 9}>
<iframe
src={embedUrl}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
frameBorder="0"
></iframe>
</AspectRatio>
</>
<ResizableWrapper
initialHeight={nodeHeight || 480}
minHeight={200}
maxHeight={1200}
onResize={handleResize}
isEditable={editor.isEditable}
className={clsx(classes.embedWrapper, {
"ProseMirror-selectednode": selected,
})}
>
<iframe
className={classes.embedIframe}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
frameBorder="0"
/>
</ResizableWrapper>
) : (
<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 +133,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>

View File

@ -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,
);
}
},
};

View File

@ -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();

View File

@ -0,0 +1,9 @@
import { atom } from "jotai";
type SearchAndReplaceAtomType = {
isOpen: boolean;
};
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
isOpen: false,
});

View File

@ -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;

View File

@ -0,0 +1,10 @@
.findDialog{
@media print {
display: none;
}
}
.findDialog div[data-position="right"].mantine-Input-section {
justify-content: right;
padding-right: 8px;
}

View File

@ -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",

View File

@ -0,0 +1,145 @@
import React, { FC } from "react";
import { IconCheck, IconPalette } from "@tabler/icons-react";
import {
ActionIcon,
ColorSwatch,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface TableColorItem {
name: string;
color: string;
}
interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>;
}
const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
{ name: "Yellow", color: "#fef1b4" },
{ name: "Red", color: "#ffbead" },
{ name: "Pink", color: "#ffc7fe" },
{ name: "Gray", color: "#eaecef" },
{ name: "Purple", color: "#c1b7f2" },
];
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
editor,
}) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const setTableCellBackground = (color: string, colorName: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
})
.run();
setOpened(false);
};
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return (
<Popover
width={200}
position="bottom"
opened={opened}
onChange={setOpened}
withArrow
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Background color")} withArrow>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Background color")}
onClick={() => setOpened(!opened)}
>
<IconPalette size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("Background color")}
</Text>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "8px",
}}
>
{TABLE_COLORS.map((item, index) => (
<UnstyledButton
key={index}
onClick={() => setTableCellBackground(item.color, item.name)}
style={{
position: "relative",
width: "24px",
height: "24px",
}}
title={t(item.name)}
>
<ColorSwatch
color={item.color || "#ffffff"}
size={24}
style={{
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
cursor: "pointer",
}}
>
{currentColor === item.color && (
<IconCheck
size={18}
style={{
color:
item.color === "" || item.color.startsWith("#F")
? "#000000"
: "#ffffff",
}}
/>
)}
</ColorSwatch>
</UnstyledButton>
))}
</div>
</Stack>
</Popover.Dropdown>
</Popover>
);
};

View File

@ -12,8 +12,11 @@ import {
IconColumnRemove,
IconRowRemove,
IconSquareToggle,
IconTableRow,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@ -45,6 +48,10 @@ export const TableCellMenu = React.memo(
editor.chain().focus().deleteRow().run();
}, [editor]);
const toggleHeaderCell = useCallback(() => {
editor.chain().focus().toggleHeaderCell().run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
@ -60,6 +67,9 @@ export const TableCellMenu = React.memo(
shouldShow={shouldShow}
>
<ActionIcon.Group>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
@ -103,6 +113,17 @@ export const TableCellMenu = React.memo(
<IconRowRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header cell")}>
<ActionIcon
onClick={toggleHeaderCell}
variant="default"
size="lg"
aria-label={t("Toggle header cell")}
>
<IconTableRow size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
);

View File

@ -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}

View File

@ -0,0 +1,109 @@
import React, { FC } from "react";
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconCheck,
} from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>;
}
interface AlignmentItem {
name: string;
icon: React.ElementType;
command: () => void;
isActive: () => boolean;
value: string;
}
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const items: AlignmentItem[] = [
{
name: "Align left",
value: "left",
isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
value: "center",
isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
value: "right",
isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
];
const activeItem = items.find((item) => item.isActive()) || items[0];
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
transitionProps={{ transition: 'pop' }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Text alignment")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea.Autosize type="scroll" mah={300}>
<Button.Group orientation="vertical">
{items.map((item, index) => (
<Button
key={index}
variant="default"
leftSection={<item.icon size={16} />}
rightSection={
item.isActive() && <IconCheck size={16} />
}
justify="left"
fullWidth
onClick={() => {
item.command();
setOpened(false);
}}
style={{ border: "none" }}
>
{t(item.name)}
</Button>
))}
</Button.Group>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
};

View File

@ -10,8 +10,6 @@ import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@ -25,6 +23,8 @@ import {
MathInline,
TableCell,
TableRow,
TableHeader,
CustomTable,
TrailingNode,
TiptapImage,
Callout,
@ -36,6 +36,7 @@ import {
Drawio,
Excalidraw,
Embed,
SearchAndReplace,
Mention,
} from "@docmost/editor-ext";
import {
@ -73,6 +74,7 @@ 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);
@ -158,7 +160,7 @@ export const mainExtensions = [
return ReactNodeViewRenderer(MentionView);
},
}),
Table.configure({
CustomTable.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,
@ -213,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[];
@ -229,4 +249,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -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>
);
}

View File

@ -1,11 +1,5 @@
import "@/features/editor/styles/index.css";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
@ -45,6 +39,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 +47,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,11 +67,15 @@ 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(
yjsConnectionStatusAtom,
yjsConnectionStatusAtom
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
@ -85,67 +85,126 @@ 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().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
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]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@ -158,6 +217,10 @@ export default function PageEditor({
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
event.preventDefault();
return true;
}
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
@ -199,7 +262,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider?.status],
[pageId, editable, remoteProvider]
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -215,7 +278,12 @@ export default function PageEditor({
}, 3000);
const handleActiveCommentEvent = (event) => {
const { commentId } = event.detail;
const { commentId, resolved } = event.detail;
if (resolved) {
return;
}
setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true });
@ -232,7 +300,7 @@ export default function PageEditor({
return () => {
document.removeEventListener(
"ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent,
handleActiveCommentEvent
);
};
}, []);
@ -252,29 +320,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,11 +335,54 @@ 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} />
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editor.isEditable && (
<div>
<EditorBubbleMenu editor={editor} />
@ -308,21 +396,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>
);
}

View File

@ -142,6 +142,11 @@
.comment-mark {
background: rgba(255, 215, 0, 0.14);
border-bottom: 2px solid rgb(166, 158, 12);
&.resolved {
background: none;
border-bottom: none;
}
}
.comment-highlight {
@ -187,7 +192,7 @@
mask-size: 100% 100%;
background-color: currentColor;
& -open {
&-open {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
.search-result{
background: #ffff65;
color: #212529;
}
.search-result-current{
background: #ffc266 !important;
color: #212529;
}

View File

@ -9,5 +9,6 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";

View File

@ -0,0 +1,34 @@
/* Ordered list type cycling based on nesting depth */
ol,
ol ol ol ol,
ol ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol ol {
list-style-type: decimal;
}
ol ol,
ol ol ol ol ol,
ol ol ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol ol ol {
list-style-type: lower-alpha;
}
ol ol ol,
ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol ol ol ol {
list-style-type: lower-roman;
}
ol {
list-style-position: outside;
margin-left: 0.25rem;
}
/* Nested list spacing */
ol ol,
ol ul,
ul ol {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
}

View File

@ -4,6 +4,7 @@
overflow-x: auto;
& table {
overflow-x: hidden;
min-width: 700px !important;
}
}
@ -38,8 +39,8 @@
th {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: left;
@ -66,8 +67,54 @@
position: absolute;
z-index: 2;
}
}
}
/* Table cell background colors with dark mode support */
.ProseMirror {
table {
@mixin dark {
/* Blue */
td[data-background-color="#b4d5ff"],
th[data-background-color="#b4d5ff"] {
background-color: #1a3a5c !important;
}
/* Green */
td[data-background-color="#acf5d2"],
th[data-background-color="#acf5d2"] {
background-color: #1a4d3a !important;
}
/* Yellow */
td[data-background-color="#fef1b4"],
th[data-background-color="#fef1b4"] {
background-color: #7c5014 !important;
}
/* Red */
td[data-background-color="#ffbead"],
th[data-background-color="#ffbead"] {
background-color: #5c2a23 !important;
}
/* Pink */
td[data-background-color="#ffc7fe"],
th[data-background-color="#ffc7fe"] {
background-color: #4d2a4d !important;
}
/* Gray */
td[data-background-color="#eaecef"],
th[data-background-color="#eaecef"] {
background-color: #2a2e33 !important;
}
/* Purple */
td[data-background-color="#c1b7f2"],
th[data-background-color="#c1b7f2"] {
background-color: #3a2f5c !important;
}
}
}
}

View File

@ -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);
}}
/>
);
}

View File

@ -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;
}

View 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;
}

View File

@ -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,
});

View File

@ -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;
}
}

View File

@ -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()}

View File

@ -1,5 +1,5 @@
import { Modal, Button, Group, Text } from "@mantine/core";
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
import { duplicatePage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
@ -30,7 +30,7 @@ export default function CopyPageModal({
if (!targetSpace) return;
try {
const copiedPage = await copyPageToSpace({
const copiedPage = await duplicatePage({
pageId,
spaceId: targetSpace.id,
});

View File

@ -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>
@ -201,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
{t("Delete")}
{t("Move to trash")}
</Menu.Item>
</>
)}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>
</>
);
}

View File

@ -4,26 +4,37 @@ import { useTranslation } from "react-i18next";
type UseDeleteModalProps = {
onConfirm: () => void;
isPermanent?: boolean;
};
export function useDeletePageModal() {
const { t } = useTranslation();
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
const openDeleteModal = ({
onConfirm,
isPermanent = false,
}: UseDeleteModalProps) => {
modals.openConfirmModal({
title: t("Are you sure you want to delete this page?"),
title: isPermanent
? t("Are you sure you want to delete this page?")
: t("Move this page to trash?"),
children: (
<Text size="sm">
{t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
)}
{isPermanent
? t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
)
: t("Pages in trash will be permanently deleted after 30 days.")}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
labels: {
confirm: isPermanent ? t("Delete") : t("Move to trash"),
cancel: t("Cancel"),
},
confirmProps: { color: "red" },
onConfirm,
});
};
return { openDeleteModal } as const;
}
}

View File

@ -1,9 +1,12 @@
import {
InfiniteData,
QueryKey,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
keepPreviousData,
} from "@tanstack/react-query";
import {
createPage,
@ -14,6 +17,9 @@ import {
movePage,
getPageBreadcrumbs,
getRecentChanges,
getAllSidebarPages,
getDeletedPages,
restorePage,
} from "@/features/page/services/page-service";
import {
IMovePage,
@ -22,12 +28,17 @@ import {
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
export function usePageQuery(
pageInput: Partial<IPageInput>,
@ -56,7 +67,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" });
},
@ -64,10 +77,7 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>([
"pages",
data.slugId,
]);
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
@ -80,6 +90,14 @@ 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 +111,30 @@ export function useUpdatePageMutation() {
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
updatePage(data);
invalidateOnUpdatePage(
data.spaceId,
data.parentPageId,
data.id,
data.title,
data.icon,
);
},
});
}
export function useRemovePageMutation() {
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: () => {
notifications.show({ message: "Page moved to trash" });
queryClient.invalidateQueries({
predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" });
},
});
}
@ -100,9 +142,16 @@ export function useUpdatePageMutation() {
export function useDeletePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId),
onSuccess: () => {
mutationFn: (pageId: string) => deletePage(pageId, true),
onSuccess: (data, pageId) => {
notifications.show({ message: t("Page deleted successfully") });
invalidateOnDeletePage(pageId);
// Invalidate to refresh trash lists
queryClient.invalidateQueries({
predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
@ -113,15 +162,101 @@ export function useDeletePageMutation() {
export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
});
}
export function useRestorePageMutation() {
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
return useMutation({
mutationFn: (pageId: string) => restorePage(pageId),
onSuccess: async (restoredPage) => {
notifications.show({ message: "Page restored successfully" });
// Add the restored page back to the tree
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
// Check if the page already exists in the tree (it shouldn't)
if (!treeApi.find(restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
slugId: restoredPage.slugId,
name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false,
children: [],
};
// Determine the parent and index
const parentId = restoredPage.parentPageId || null;
let index = 0;
if (parentId) {
const parentNode = treeApi.find(parentId);
if (parentNode) {
index = parentNode.children?.length || 0;
}
} else {
// Root level page
index = treeApi.data.length;
}
// Add the node to the tree
treeApi.create({
parentId,
index,
data: nodeData,
});
// Update the tree data
setTreeData(treeApi.data);
// Emit websocket event to sync with other users
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: restoredPage.spaceId,
payload: {
parentId,
index,
data: nodeData,
},
});
}, 50);
}
// await queryClient.invalidateQueries({ queryKey: ["sidebar-pages", restoredPage.spaceId] });
// Also invalidate deleted pages query to refresh the trash list
await queryClient.invalidateQueries({
queryKey: ["trash-list", restoredPage.spaceId],
});
},
onError: (error) => {
notifications.show({ message: "Failed to restore page", color: "red" });
},
});
}
export function useGetSidebarPagesQuery(
data: SidebarPagesParams,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
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 +284,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 +305,195 @@ export function useRecentChangesQuery(
refetchOnMount: true,
});
}
export function useDeletedPagesQuery(
spaceId: string,
params?: QueryParams,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
queryKey: ["trash-list", spaceId, params],
queryFn: () => getDeletedPages(spaceId, params),
enabled: !!spaceId,
placeholderData: keepPreviousData,
refetchOnMount: true,
staleTime: 0,
});
}
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"],
});
}

View File

@ -7,9 +7,12 @@ import {
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
} from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/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);
@ -28,8 +31,21 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
return req.data;
}
export async function deletePage(pageId: string): Promise<void> {
await api.post("/pages/delete", { pageId });
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
await api.post("/pages/delete", { pageId, permanentlyDelete });
}
export async function getDeletedPages(
spaceId: string,
params?: QueryParams,
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/trash", { spaceId, ...params });
return req.data;
}
export async function restorePage(pageId: string): Promise<IPage> {
const response = await api.post<IPage>("/pages/restore", { pageId });
return response.data;
}
export async function movePage(data: IMovePage): Promise<void> {
@ -40,8 +56,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
await api.post<void>("/pages/move-to-space", data);
}
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
const req = await api.post<IPage>("/pages/copy-to-space", data);
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
const req = await api.post<IPage>("/pages/duplicate", data);
return req.data;
}
@ -52,6 +68,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 +134,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,

View File

@ -0,0 +1,41 @@
import { Modal, Text, ScrollArea } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
interface Props {
opened: boolean;
onClose: () => void;
pageTitle: string;
pageContent: any;
}
export default function TrashPageContentModal({
opened,
onClose,
pageTitle,
pageContent,
}: Props) {
const { t } = useTranslation();
const title = pageTitle || t("Untitled");
return (
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Preview")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body p={0}>
<ScrollArea h="650" w="100%" scrollbarSize={5}>
<ReadonlyPageEditor title={title} content={pageContent} />
</ScrollArea>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@ -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);
}
);

View File

@ -1,8 +1,14 @@
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import {
NodeApi,
NodeRendererProps,
Tree,
TreeApi,
SimpleTree,
} 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 +30,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 +41,7 @@ import {
appendNodeChildren,
buildTree,
buildTreeWithChildren,
mergeRootTrees,
updateTreeNodeIcon,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@ -62,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx";
import { duplicatePage } from "../../services/page-service.ts";
interface SpaceTreeProps {
spaceId: string;
@ -86,8 +97,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
const rootElement = useRef<HTMLDivElement>();
const [isRootReady, setIsRootReady] = useState(false);
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const mergedRef = useMergedRef((element) => {
rootElement.current = element;
if (element && !isRootReady) {
setIsRootReady(true);
}
}, sizeRef);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
@ -104,17 +121,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 +157,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,
});
@ -195,16 +212,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
}, [currentPage?.id]);
// Clean up tree API on unmount
useEffect(() => {
if (treeApiRef.current) {
return () => {
// @ts-ignore
setTreeApi(treeApiRef.current);
}
}, [treeApiRef.current]);
setTreeApi(null);
};
}, [setTreeApi]);
return (
<div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && (
{isRootReady && rootElement.current && (
<Tree
data={data.filter((node) => node?.spaceId === spaceId)}
disableDrag={readOnly}
@ -213,7 +231,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
{...controllers}
width={width}
height={rootElement.current.clientHeight}
ref={treeApiRef}
ref={(ref) => {
treeApiRef.current = ref;
if (ref) {
//@ts-ignore
setTreeApi(ref);
}
}}
openByDefault={false}
disableMultiSelection={true}
className={classes.tree}
@ -237,6 +261,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 +287,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 +298,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 +321,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 = () => {
@ -384,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<span className={classes.text}>{node.data.name || t("untitled")}</span>
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} />
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
{!tree.props.disableEdit && (
<CreateNode
@ -437,13 +456,16 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
interface NodeMenuProps {
node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>;
spaceId: string;
}
function NodeMenu({ node, treeApi }: NodeMenuProps) {
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
@ -462,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
notifications.show({ message: t("Link copied") });
};
const handleDuplicatePage = async () => {
try {
const duplicatedPage = await duplicatePage({
pageId: node.id,
});
// Find the index of the current node
const parentId =
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
? null
: node.parent?.id;
const siblings = parentId ? node.parent.children : treeApi?.props.data;
const currentIndex =
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
const newIndex = currentIndex + 1;
// Add the duplicated page to the tree
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
children: [],
};
// Update local tree
const simpleTree = new SimpleTree(data);
simpleTree.create({
parentId,
index: newIndex,
data: treeNodeData,
});
setData(simpleTree.data);
// Emit socket event
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: spaceId,
payload: {
parentId,
index: newIndex,
data: treeNodeData,
},
});
}, 50);
notifications.show({
message: t("Page duplicated successfully"),
});
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
color: "red",
});
}
};
return (
<>
<Menu shadow="md" width={200}>
@ -506,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
@ -525,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
openCopyPageModal();
}}
>
{t("Copy")}
{t("Copy to space")}
</Menu.Item>
<Menu.Divider />
@ -538,7 +633,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Delete")}
{t("Move to trash")}
</Menu.Item>
</>
)}
@ -576,6 +671,12 @@ interface PageArrowProps {
}
function PageArrow({ node, onExpandTree }: PageArrowProps) {
useEffect(() => {
if (node.isOpen) {
onExpandTree();
}
}, []);
return (
<ActionIcon
size={20}

View File

@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate, useParams } from "react-router-dom";
import {
useCreatePageMutation,
useDeletePageMutation,
useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
@ -28,7 +28,7 @@ export function useTreeMutation<T>(spaceId: string) {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const deletePageMutation = useDeletePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
@ -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,9 +206,26 @@ 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]);
await removePageMutation.mutateAsync(args.ids[0]);
const node = tree.find(args.ids[0]);
if (!node) {
@ -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));
}

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