Compare commits

...

38 Commits

Author SHA1 Message Date
Philipinho af6d9672f5 uuid validation 2026-04-12 21:50:36 +01:00
Philipinho 2e4e5ab47f cleanup 2026-04-12 21:34:21 +01:00
Philipinho cd5fa445be turn off templates 2026-04-12 21:05:48 +01:00
Philipinho a8565e57fb fix 2026-04-12 20:57:39 +01:00
Philipinho 0fb89f52f0 cleanup tabs 2026-04-12 20:52:21 +01:00
Philipinho 9226014fe9 fix sidebar 2026-04-12 20:44:22 +01:00
Philipinho c9531acf04 rename migrations 2026-04-12 20:43:05 +01:00
Philipinho b3ab12d732 Merge branch 'main' 2026-04-12 20:29:38 +01:00
Philipinho 74118e11d7 feat: favorites and templates(ee) 2026-04-12 20:16:46 +01:00
Philip Okugbe 57efb91bd3 feat(ee): ai chat (#2098)
* feat: ai chat

* feat: ai chat

* sync

* cleanup

* view space button
2026-04-10 19:23:47 +01:00
Philip Okugbe da9b43681e feat: watch space (#2096) 2026-04-09 00:37:51 +01:00
Philipinho 4966f9b152 fix(deps): package updates 2026-04-07 10:24:46 +01:00
Philipinho e1bbceb9a6 fix: logs 2026-04-07 10:10:41 +01:00
Philip Okugbe 895c1817ae feat: bug fixes (#2084)
* handle enter in inline code

* fix: duplicate comment cache

* track link nodes (backlinks)

* fix en-US translation

* fix internal a-links

* overrides

* 0.71.1
2026-04-05 13:45:36 +01:00
Philip Okugbe 642024ba9d New Crowdin updates (#2078) 2026-03-31 21:14:41 +01:00
Philipinho 147d028036 v0.71.0 2026-03-31 20:42:37 +01:00
Philipinho 992691e6e0 fix module import 2026-03-31 20:41:09 +01:00
Philip Okugbe 9aaa6c731c feat: add AI_EMBEDDING_SUPPORTS_MRL env var to decouple pgvector dimensions from model API (#2079)
Some embedding models don't accept a `dimensions` parameter. This adds
an optional env var that controls whether the dimension is sent to the
model API, while always using it for pgvector indexing. Preset models
have this handled automatically; the env var allows explicit override
for custom models.
2026-03-31 19:39:49 +01:00
Philipinho fd91b11c6c pin version 2026-03-31 16:06:44 +01:00
Philipinho af8b0ddf3a sync 2026-03-31 16:05:09 +01:00
Philip Okugbe 879aa2c3d8 feat: page update notifications (#2074)
* feat: watchers notification and email preferences

* fix: email copy

* digests

* clean up

* fix

* clean up

* move backlinks queue-up to history processor

* fix

* fix keys

* feat: group notifications

* filter

* adjust email digest window
2026-03-31 16:03:59 +01:00
Philip Okugbe c180d0e487 feat: ratelimits (#2073)
* feat: rate limits

* ip
2026-03-30 15:38:44 +01:00
Philip Okugbe a062f7a165 fix: enhance confluence importer (#2072)
* fix placeholder

* min resize dimensions

* fix media links

* fix
2026-03-30 13:16:40 +01:00
Philip Okugbe cbd0dd4a0b feat: indexes (#2071) 2026-03-29 20:29:12 +01:00
Philip Okugbe 2d6d829581 New translations translation.json (English) (#2066) 2026-03-29 16:25:45 +01:00
Philipinho 5cea30cc5c fix markdown paste 2026-03-29 16:11:21 +01:00
Philipinho bca85a49d6 pin marked version 2026-03-29 03:03:35 +01:00
Philipinho c9cdfa0f17 fix 2026-03-29 02:20:56 +01:00
Philip Okugbe 412962204c fix: editor fixes (#2067)
* autojoiner

* fix marked

* return clipboardTextSerializer as markdown

* fix clipboardTextSerializer for single lines

* cleanup two preceeding spaces in ordered lists item

* fix extra paragraph in task list

* don't zip sinple page exports
2026-03-29 02:19:09 +01:00
Olivier Lambert a42ac3d450 fix: strip trailing whitespace-only paragraphs from pasted content (#2050) 2026-03-28 22:26:47 +00:00
Philipinho 642c92f779 fix select 2026-03-28 20:34:44 +00:00
Philipinho ccb35517bb sync 2026-03-28 20:29:31 +00:00
Philip Okugbe cbdb37ed0a New Crowdin updates (#2061) 2026-03-28 20:29:06 +00:00
Julien Fontanet aa27d57624 fix: notification items are now real links (#2039)
Replace UnstyledButton with UnstyledButton component={Link} so each
notification renders as a real anchor element. Regular left-clicks use
SPA navigation and close the popover; Ctrl/Cmd/middle-click open the
page in a new tab. All click types mark the notification as read.
2026-03-28 20:23:21 +00:00
Philip Okugbe 3829b6cbef feat(ee): viewer comments (#2060) 2026-03-28 19:32:52 +00:00
Philipinho 17da762984 overrides 2026-03-28 19:28:22 +00:00
Philipinho 859f16740b tooltip portal 2026-03-28 19:19:00 +00:00
Philip Okugbe 7981ef462e feat(editor): audio and PDF nodes (#2064)
* use local resizable

* feat: aduio

* support audio imports

* feat: use confluence real file names

* cleanup

* error handling

* hide notice

* add audio

* fix pulse

* Fix import and export

* unify pulse

* hide in readonly mode

* keywords

* keyword

* translations

* better sort

* feat: PDF embed

* cleanup

* remove audio menu

* open active

* hide focus on readonly mode

* increase iframe default dimension
2026-03-28 17:33:29 +00:00
276 changed files with 15615 additions and 1793 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.70.3",
"version": "0.71.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.6",
"axios": "1.13.6",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@@ -67,7 +67,7 @@
"@types/node": "22.19.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -80,6 +80,6 @@
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1"
"vite": "8.0.5"
}
}
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
@@ -351,6 +352,12 @@
"Divider": "Trennlinie",
"Quote": "Zitat",
"Image": "Bild",
"Audio": "Audio.",
"Embed PDF": "PDF einbetten",
"Upload and embed a PDF file.": "Laden Sie eine PDF-Datei hoch und betten Sie sie ein.",
"Embed as PDF": "Als PDF einbetten",
"Failed to load PDF": "Fehler beim Laden der PDF",
"Convert to attachment": "In Anhang umwandeln",
"File attachment": "Dateianhang",
"Toggle block": "Block umschalten",
"Callout": "Hinweisbox",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"Allow viewers to comment": "Zuschauern erlauben, Kommentare zu hinterlassen",
"Allow viewers to add comments on pages in this space.": "Erlauben Sie Zuschauern, Kommentare auf Seiten in diesem Bereich hinzuzufügen.",
"Toggle viewer comments": "Zuschauerkommentare umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Page permissions": "Seitenberechtigungen",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> hat Sie auf einer Seite erwähnt",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> hat Ihnen Bearbeitungszugriff auf eine Seite gegeben",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> hat Ihnen Ansichtsrechte für eine Seite gegeben",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> hat eine Seite aktualisiert.",
"Watch page": "Seite beobachten",
"Stop watching": "Beobachtung beenden",
"Email notifications": "E-Mail-Benachrichtigungen",
"Page updates": "Seitenaktualisierungen",
"Get notified when pages you watch are updated.": "Erhalten Sie eine Benachrichtigung, wenn Seiten, die Sie beobachten, aktualisiert werden.",
"Page mentions": "Seiten-Erwähnungen",
"Get notified when someone mentions you on a page.": "Erhalten Sie eine Benachrichtigung, wenn Sie jemand auf einer Seite erwähnt.",
"Comment mentions": "Kommentar-Erwähnungen",
"Get notified when someone mentions you in a comment.": "Erhalten Sie eine Benachrichtigung, wenn Sie jemand in einem Kommentar erwähnt.",
"New comments": "Neue Kommentare",
"Get notified about new comments on threads you participate in.": "Erhalten Sie eine Benachrichtigung über neue Kommentare in Threads, an denen Sie teilnehmen.",
"Resolved comments": "Erledigte Kommentare",
"Get notified when your comment is resolved.": "Erhalten Sie eine Benachrichtigung, wenn Ihr Kommentar erledigt wurde.",
"You are now watching this page": "Sie beobachten diese Seite jetzt",
"You are no longer watching this page": "Sie beobachten diese Seite nicht mehr",
"Direct": "Direkt",
"Updates": "Aktualisierungen",
"Today": "Heute",
"Yesterday": "Gestern",
"This week": "Diese Woche",
+323 -255
View File
@@ -1,12 +1,13 @@
{
"Account": "Account ",
"Account": "Account",
"Active": "Active",
"Add": "Add.",
"Add": "Add",
"Add group members": "Add group members",
"Add groups": "Add groups",
"Add members": "Add members",
"Add to groups": "Add to groups",
"Add space members": "Add space members",
"Add to favorites": "Add to favorites",
"Admin": "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 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 delete this page?",
@@ -44,24 +45,24 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
"Details": "Details",
"e.g ACME": "e.g. ACME",
"e.g ACME Inc": "e.g. ACME Inc",
"e.g Developers": "e.g. Developers",
"e.g Group for developers": "e.g. Group for developers",
"e.g product": "e.g. product",
"e.g Product Team": "e.g. Product Team",
"e.g Sales": "e.g. Sales",
"e.g Space for product team": "e.g. Space for product team",
"e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate",
"e.g ACME": "e.g ACME",
"e.g ACME Inc": "e.g ACME Inc",
"e.g Developers": "e.g Developers",
"e.g Group for developers": "e.g Group for developers",
"e.g product": "e.g product",
"e.g Product Team": "e.g Product Team",
"e.g Sales": "e.g Sales",
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit",
"Read": "Read.",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
"Enter a strong password": "Enter a strong password",
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]",
"enter valid emails addresses": "Enter valid email addresses",
"enter valid emails addresses": "enter valid emails addresses",
"Enter your current password": "Enter your current password",
"enter your full name": "Enter your full name",
"enter your full name": "enter your full name",
"Enter your new password": "Enter your new password",
"Enter your new preferred email": "Enter your new preferred email",
"Enter your password": "Enter your password",
@@ -74,6 +75,9 @@
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"Favorite spaces": "Favorite spaces",
"Favorite spaces appear here": "Favorite spaces appear here",
"Favorites": "Favorites",
"Full access": "Full access",
"Full page width": "Full page width",
"Full width": "Full width",
@@ -87,11 +91,12 @@
"Import pages": "Import pages",
"Import pages & space settings": "Import pages & space settings",
"Importing pages": "Importing pages",
"invalid invitation link": "Invalid invitation link",
"invalid invitation link": "invalid invitation link",
"Invitation signup": "Invitation signup",
"Invite by email": "Invite by email",
"Invite members": "Invite members",
"Invite new members": "Invite new members",
"Invite People": "Invite People",
"Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.",
"Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
"Join the workspace": "Join the workspace",
@@ -113,7 +118,7 @@
"New email": "New email",
"New page": "New page",
"New password": "New password",
"No group found": "No group found.",
"No group found": "No group found",
"No page history saved yet.": "No page history saved yet.",
"No pages yet": "No pages yet",
"No shared pages": "No shared pages",
@@ -139,6 +144,7 @@
"Profile": "Profile",
"Recently updated": "Recently updated",
"Remove": "Remove",
"Remove from favorites": "Remove from favorites",
"Remove group member": "Remove group member",
"Remove space member": "Remove space member",
"Restore": "Restore",
@@ -149,56 +155,57 @@
"Search for users": "Search for users",
"Search for users and groups": "Search for users and groups",
"Search...": "Search...",
"Select language": "Select language.",
"Select role": "Select role.",
"Select role to assign to all invited members": "Select role to assign to all invited members.",
"Select theme": "Select theme.",
"Send invitation": "Send invitation.",
"Invitation sent": "Invitation sent.",
"Settings": "Settings.",
"Setup workspace": "Setup workspace.",
"Sign In": "Sign In.",
"Sign Up": "Sign Up.",
"Slug": "Slug.",
"Space": "Space.",
"Space description": "Space description.",
"Space menu": "Space menu.",
"Space name": "Space name.",
"Space settings": "Space settings.",
"Space slug": "Space slug.",
"Spaces": "Spaces.",
"Spaces you belong to": "Spaces you belong to.",
"No space found": "No space found.",
"Search for spaces": "Search for spaces.",
"Select language": "Select language",
"Select role": "Select role",
"Select role to assign to all invited members": "Select role to assign to all invited members",
"Select theme": "Select theme",
"Send invitation": "Send invitation",
"Invitation sent": "Invitation sent",
"Settings": "Settings",
"Setup workspace": "Setup workspace",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Slug": "Slug",
"Space": "Space",
"Space description": "Space description",
"Space menu": "Space menu",
"Space name": "Space name",
"Space settings": "Space settings",
"Space slug": "Space slug",
"Spaces": "Spaces",
"Spaces you belong to": "Spaces you belong to",
"No space found": "No space found",
"Search for spaces": "Search for spaces",
"Start typing to search...": "Start typing to search...",
"Status": "Status.",
"Successfully imported": "Successfully imported.",
"Successfully restored": "Successfully restored.",
"System settings": "System settings.",
"Theme": "Theme.",
"Status": "Status",
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
"System settings": "System settings",
"Templates": "Templates",
"Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width.",
"Toggle full page width": "Toggle full page width",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"untitled": "untitled.",
"Untitled": "Untitled.",
"Updated successfully": "Updated successfully.",
"User": "User.",
"Workspace": "Workspace.",
"Workspace Name": "Workspace Name.",
"Workspace settings": "Workspace settings.",
"untitled": "untitled",
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
"You can change your password here.": "You can change your password here.",
"Your Email": "Your Email.",
"Your Email": "Your Email",
"Your import is complete.": "Your import is complete.",
"Your name": "Your name.",
"Your Name": "Your Name.",
"Your password": "Your password.",
"Your name": "Your name",
"Your Name": "Your Name",
"Your password": "Your password",
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle.",
"Comments": "Comments.",
"404 page not found": "404 page not found.",
"Sidebar toggle": "Sidebar toggle",
"Comments": "Comments",
"404 page not found": "404 page not found",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
"Take me back to homepage": "Take me back to homepage.",
"Forgot password": "Forgot password.",
"Take me back to homepage": "Take me back to homepage",
"Forgot password": "Forgot password",
"Forgot your password?": "Forgot your password?",
"A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.",
"Send reset link": "Send reset link",
@@ -222,16 +229,16 @@
"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.",
"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.",
"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.",
"Resolved": "Resolved",
"No active comments.": "No active comments.",
"Revoke invitation": "Revoke invitation",
"Revoke": "Revoke",
@@ -241,9 +248,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.",
"Copy to space": "Copy to space",
"Copied": "Copied",
"Duplicate": "Duplicate.",
"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.",
@@ -251,7 +258,7 @@
"Are you sure you want to delete this space?": "Are you sure you want to delete this space?",
"Delete this space with all its pages and data.": "Delete this space with all its pages and data.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "All pages, comments, attachments and permissions in this space will be deleted irreversibly.",
"Confirm space name": "Confirm space name.",
"Confirm space name": "Confirm space name",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Type the space name <b>{{spaceName}}</b> to confirm your action.",
"Format": "Format",
"Include subpages": "Include subpages",
@@ -267,7 +274,7 @@
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
"Justify": "Justify.",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",
"Delete column": "Delete column",
@@ -312,7 +319,7 @@
"Pink": "Pink",
"Gray": "Gray",
"Embed link": "Embed link",
"Invalid {{provider}} embed link": "Invalid {{provider}} embed link.",
"Invalid {{provider}} embed link": "Invalid {{provider}} embed link",
"Embed {{provider}}": "Embed {{provider}}",
"Enter {{provider}} link to embed": "Enter {{provider}} link to embed",
"Bold": "Bold",
@@ -341,38 +348,45 @@
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
"Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table.",
"Table": "Table",
"Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.",
"Video": "Video.",
"Divider": "Divider.",
"Quote": "Quote.",
"Image": "Image.",
"File attachment": "File attachment.",
"Toggle block": "Toggle block.",
"Callout": "Callout.",
"Video": "Video",
"Divider": "Divider",
"Quote": "Quote",
"Image": "Image",
"Audio": "Audio",
"Embed PDF": "Embed PDF",
"Upload and embed a PDF file.": "Upload and embed a PDF file.",
"Embed as PDF": "Embed as PDF",
"Failed to load PDF": "Failed to load PDF",
"Convert to attachment": "Convert to attachment",
"File attachment": "File attachment",
"Toggle block": "Toggle block",
"Callout": "Callout",
"Insert callout notice.": "Insert callout notice.",
"Math inline": "Math inline.",
"Math inline": "Math inline",
"Insert inline math equation.": "Insert inline math equation.",
"Math block": "Math block.",
"Insert math equation": "Insert math equation.",
"Mermaid diagram": "Mermaid diagram.",
"Insert mermaid diagram": "Insert mermaid diagram.",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams.",
"Insert current date": "Insert current date.",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams.",
"Multiple": "Multiple.",
"Math block": "Math block",
"Insert math equation": "Insert math equation",
"Mermaid diagram": "Mermaid diagram",
"Insert mermaid diagram": "Insert mermaid diagram",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
"Insert current date": "Insert current date",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Heading {{level}}.",
"Toggle title": "Toggle title.",
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands.",
"Heading {{level}}": "Heading {{level}}",
"Toggle title": "Toggle title",
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
"Write...": "Write...",
"Column count": "Column count",
"{{count}} Columns": "{{count}} Columns",
@@ -382,27 +396,27 @@
"Wide center": "Wide center",
"Left wide": "Left wide",
"Right wide": "Right wide",
"Names do not match": "Names do not match.",
"Today, {{time}}": "Today, {{time}}.",
"Yesterday, {{time}}": "Yesterday, {{time}}.",
"Space created successfully": "Space created successfully.",
"Space updated successfully": "Space updated successfully.",
"Space deleted successfully": "Space deleted successfully.",
"Members added successfully": "Members added successfully.",
"Member removed successfully": "Member removed successfully.",
"Member role updated successfully": "Member role updated successfully.",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>.",
"Created at: {{time}}": "Created at: {{time}}.",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}.",
"Word count: {{wordCount}}": "Word count: {{wordCount}}.",
"Character count: {{characterCount}}": "Character count: {{characterCount}}.",
"New update": "New update.",
"{{latestVersion}} is available": "{{latestVersion}} is available.",
"Default page edit mode": "Default page edit mode.",
"Names do not match": "Names do not match",
"Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"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.",
"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.",
"Deactivate member": "Deactivate member",
"Activate member": "Activate member",
@@ -411,37 +425,40 @@
"Deactivate": "Deactivate",
"Activate": "Activate",
"Deactivated": "Deactivated",
"Move": "Move.",
"Move page": "Move page.",
"Move": "Move",
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Table of contents": "Table of contents.",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share.",
"Public sharing": "Public sharing.",
"Shared by": "Shared by.",
"Shared at": "Shared at.",
"Inherits public sharing from": "Inherits public sharing from.",
"Share to web": "Share to web.",
"Shared to web": "Shared to web.",
"Anyone with the link can view this page": "Anyone with the link can view this page.",
"Make this page publicly accessible": "Make this page publicly accessible.",
"Include sub-pages": "Include sub-pages.",
"Make sub-pages public too": "Make sub-pages public too.",
"Allow search engines to index page": "Allow search engines to index page.",
"Open page": "Open page.",
"Page": "Page.",
"Delete public share link": "Delete public share link.",
"Delete share": "Delete share.",
"Share": "Share",
"Public sharing": "Public sharing",
"Shared by": "Shared by",
"Shared at": "Shared at",
"Inherits public sharing from": "Inherits public sharing from",
"Share to web": "Share to web",
"Shared to web": "Shared to web",
"Anyone with the link can view this page": "Anyone with the link can view this page",
"Make this page publicly accessible": "Make this page publicly accessible",
"Include sub-pages": "Include sub-pages",
"Make sub-pages public too": "Make sub-pages public too",
"Allow search engines to index page": "Allow search engines to index page",
"Open page": "Open page",
"Page": "Page",
"Delete public share link": "Delete public share link",
"Delete share": "Delete share",
"Are you sure you want to delete this shared link?": "Are you sure you want to delete this shared link?",
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here.",
"Share deleted successfully": "Share deleted successfully.",
"Share not found": "Share not found.",
"Failed to share page": "Failed to share page.",
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
"Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found",
"Failed to share page": "Failed to share page",
"Disable public sharing": "Disable public sharing",
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
@@ -454,135 +471,136 @@
"Public sharing is disabled": "Public sharing is disabled",
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
"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 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.",
"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": "View 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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"Verify": "Verify",
"Trash": "Trash",
"Pages in trash will be permanently deleted after {{count}} days.": "Pages in trash will be permanently deleted after {{count}} days.",
"Deleted": "Deleted.",
"No pages in trash": "No pages in trash.",
"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 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.",
"Subpages": "Subpages.",
"Failed to load subpages": "Failed to load subpages.",
"No subpages": "No subpages.",
"Subpages (Child pages)": "Subpages (Child pages).",
"List all subpages of the current page": "List all subpages of the current page.",
"Attachments": "Attachments.",
"All spaces": "All spaces.",
"Unknown": "Unknown.",
"Find a space": "Find a space.",
"Search in all your spaces": "Search in all your spaces.",
"Type": "Type.",
"Enterprise": "Enterprise.",
"Download attachment": "Download attachment.",
"Allowed email domains": "Allowed email domains.",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space.",
"Enforce two-factor authentication": "Enforce two-factor authentication.",
"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",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
"Subpages (Child pages)": "Subpages (Child pages)",
"List all subpages of the current page": "List all subpages of the current page",
"Attachments": "Attachments",
"All spaces": "All spaces",
"Unknown": "Unknown",
"Find a space": "Find a space",
"Search in all your spaces": "Search in all your spaces",
"Type": "Type",
"Enterprise": "Enterprise",
"Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space",
"Enforce two-factor authentication": "Enforce two-factor authentication",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.",
"Toggle MFA enforcement": "Toggle MFA enforcement.",
"Display name": "Display name.",
"Allow signup": "Allow signup.",
"Enabled": "Enabled.",
"Advanced Settings": "Advanced Settings.",
"Enable TLS/SSL": "Enable TLS/SSL.",
"Use secure connection to LDAP server": "Use secure connection to LDAP server.",
"Group sync": "Group sync.",
"Toggle MFA enforcement": "Toggle MFA enforcement",
"Display name": "Display name",
"Allow signup": "Allow signup",
"Enabled": "Enabled",
"Advanced Settings": "Advanced Settings",
"Enable TLS/SSL": "Enable TLS/SSL",
"Use secure connection to LDAP server": "Use secure connection to LDAP server",
"Group sync": "Group sync",
"No SSO providers found.": "No SSO providers found.",
"Delete SSO provider": "Delete SSO provider.",
"Delete SSO provider": "Delete SSO provider",
"Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?",
"Action": "Action.",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration.",
"Icon": "Icon.",
"Upload image": "Upload image.",
"Action": "Action",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
@@ -617,6 +635,7 @@
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Thinking": "Thinking",
"Ask a question...": "Ask a question...",
"AI Answers": "AI Answers",
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
@@ -660,10 +679,32 @@
"More options": "More options",
"<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> mentioned you in a comment",
"<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> commented on a page",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolved a comment.",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page.",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page.",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page.",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolved a comment",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> updated a page",
"Watch page": "Watch page",
"Stop watching": "Stop watching",
"Watch space": "Watch space",
"Stop watching space": "Stop watching space",
"Email notifications": "Email notifications",
"Page updates": "Page updates",
"Get notified when pages you watch are updated.": "Receive notifications when the pages you watch are updated.",
"Page mentions": "Page mentions",
"Get notified when someone mentions you on a page.": "Receive notifications when someone mentions you on a page.",
"Comment mentions": "Comment mentions",
"Get notified when someone mentions you in a comment.": "Receive notifications when someone mentions you in a comment.",
"New comments": "New comments",
"Get notified about new comments on threads you participate in.": "Receive notifications about new comments in threads you are participating in.",
"Resolved comments": "Resolved comments",
"Get notified when your comment is resolved.": "Receive a notification when your comment is resolved.",
"You are now watching this page": "Youre now watching this page",
"You are no longer watching this page": "Youre no longer watching this page",
"You are now watching this space": "Youre now watching this space",
"You are no longer watching this space": "Youre no longer watching this space",
"Direct": "Direct",
"Updates": "Updates",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
@@ -698,30 +739,57 @@
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission",
"Removed page permission": "Removed page permission",
"Verifying your email": "Verifying your email.",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
"Check your email": "Check your email.",
"Check your email": "Check your email",
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
"We sent a verification link to your email.": "We sent a verification link to your email.",
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
"Resend verification email": "Resend verification email.",
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
"Load more": "Load more.",
"Log out of all devices": "Log out of all devices.",
"Log out of all sessions except this device": "Log out of all sessions except this device.",
"This Device": "This Device.",
"Unknown device": "Unknown device.",
"No active sessions": "No active sessions.",
"Session revoked": "Session revoked.",
"All other sessions revoked": "All other sessions revoked.",
"Last used": "Last used.",
"Created": "Created.",
"Rename": "Rename.",
"Publish": "Publish.",
"Security": "Security.",
"Enforce SSO": "Enforce SSO.",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password."
"Load more": "Load more",
"Log out of all devices": "Log out of all devices",
"Log out of all sessions except this device": "Log out of all sessions except this device",
"This Device": "This Device",
"Unknown device": "Unknown device",
"No active sessions": "No active sessions",
"Session revoked": "Session revoked",
"All other sessions revoked": "All other sessions revoked",
"Last used": "Last used",
"Created": "Created",
"Rename": "Rename",
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.",
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...",
"Chat history": "Chat history",
"Chat name": "Chat name",
"Close": "Close",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.",
"How can I help you today?": "How can I help you today?",
"New chat": "New chat",
"No chat history": "No chat history",
"No chats found": "No chats found",
"No conversations yet": "No conversations yet",
"Open full page": "Open full page",
"Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...",
"Start a new chat to see it here.": "Start a new chat to see it here.",
"Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat",
"Translate this page": "Translate this page",
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?"
}
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insertar regla horizontal",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Cita",
"Image": "Imagen",
"Audio": "Audio.",
"Embed PDF": "Adjuntar PDF",
"Upload and embed a PDF file.": "Sube y adjunta un archivo PDF.",
"Embed as PDF": "Adjuntar como PDF",
"Failed to load PDF": "Error al cargar el PDF",
"Convert to attachment": "Convertir en adjunto",
"File attachment": "Adjunto de archivo",
"Toggle block": "Alternar bloque",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
"Allow viewers to comment": "Permitir que los espectadores comenten",
"Allow viewers to add comments on pages in this space.": "Permitir que los espectadores agreguen comentarios en las páginas de este espacio.",
"Toggle viewer comments": "Activar/desactivar comentarios de los espectadores",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Page permissions": "Permisos de la página},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> te mencionó en una página",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> te dio acceso de edición a una página",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> te dio acceso de visualización a una página",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> actualizó una página.",
"Watch page": "Seguir página",
"Stop watching": "Dejar de seguir",
"Email notifications": "Notificaciones por correo electrónico",
"Page updates": "Actualizaciones de página",
"Get notified when pages you watch are updated.": "Recibe una notificación cuando se actualicen las páginas que sigues.",
"Page mentions": "Menciones en la página",
"Get notified when someone mentions you on a page.": "Recibe una notificación cuando alguien te mencione en una página.",
"Comment mentions": "Menciones en comentarios",
"Get notified when someone mentions you in a comment.": "Recibe una notificación cuando alguien te mencione en un comentario.",
"New comments": "Nuevos comentarios",
"Get notified about new comments on threads you participate in.": "Recibe una notificación sobre nuevos comentarios en los hilos donde participas.",
"Resolved comments": "Comentarios resueltos",
"Get notified when your comment is resolved.": "Recibe una notificación cuando tu comentario sea resuelto.",
"You are now watching this page": "Ahora sigues esta página",
"You are no longer watching this page": "Ya no sigues esta página",
"Direct": "Directo",
"Updates": "Actualizaciones",
"Today": "Hoy",
"Yesterday": "Ayer",
"This week": "Esta semana",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
@@ -351,6 +352,12 @@
"Divider": "Diviseur",
"Quote": "Citation",
"Image": "Image",
"Audio": "Audio.",
"Embed PDF": "Intégrer un PDF",
"Upload and embed a PDF file.": "Téléchargez et intégrez un fichier PDF.",
"Embed as PDF": "Intégrer comme PDF",
"Failed to load PDF": "Échec du chargement du PDF",
"Convert to attachment": "Convertir en pièce jointe",
"File attachment": "Pièce jointe",
"Toggle block": "Basculer le bloc",
"Callout": "Appel",
@@ -415,7 +422,7 @@
"Move page": "Déplacer la page",
"Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
"Table of contents": "",
"Table of contents": "Table des matières.",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
"Share": "Partager",
"Public sharing": "Partage public",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
"Allow viewers to comment": "Autoriser les spectateurs à commenter",
"Allow viewers to add comments on pages in this space.": "Autoriser les spectateurs à ajouter des commentaires sur les pages de cet espace.",
"Toggle viewer comments": "Basculer les commentaires des spectateurs",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Page permissions": "Autorisations de la page",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> vous a mentionné sur une page",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> vous a donné l'accès en modification à une page",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> vous a donné l'accès en lecture à une page",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> a mis à jour une page.",
"Watch page": "Surveiller la page",
"Stop watching": "Ne plus surveiller",
"Email notifications": "Notifications par e-mail",
"Page updates": "Mises à jour de la page",
"Get notified when pages you watch are updated.": "Recevez une notification lorsque les pages que vous surveillez sont mises à jour.",
"Page mentions": "Mentions sur la page",
"Get notified when someone mentions you on a page.": "Recevez une notification lorsqu'une personne vous mentionne sur une page.",
"Comment mentions": "Mentions dans les commentaires",
"Get notified when someone mentions you in a comment.": "Recevez une notification lorsqu'une personne vous mentionne dans un commentaire.",
"New comments": "Nouveaux commentaires",
"Get notified about new comments on threads you participate in.": "Recevez une notification concernant les nouveaux commentaires dans les fils auxquels vous participez.",
"Resolved comments": "Commentaires résolus",
"Get notified when your comment is resolved.": "Recevez une notification lorsque votre commentaire est résolu.",
"You are now watching this page": "Vous surveillez désormais cette page",
"You are no longer watching this page": "Vous ne surveillez plus cette page",
"Direct": "Direct",
"Updates": "Mises à jour",
"Today": "Aujourd'hui",
"Yesterday": "Hier",
"This week": "Cette semaine",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
@@ -351,6 +352,12 @@
"Divider": "Divisore",
"Quote": "Preventivo",
"Image": "Immagine",
"Audio": "Audio.",
"Embed PDF": "Incorpora PDF",
"Upload and embed a PDF file.": "Carica e incorpora un file PDF.",
"Embed as PDF": "Incorpora come PDF",
"Failed to load PDF": "Caricamento del PDF non riuscito",
"Convert to attachment": "Converti in allegato",
"File attachment": "Allegato file",
"Toggle block": "Attiva blocco",
"Callout": "Avviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Allow viewers to comment": "Consenti agli utenti di commentare",
"Allow viewers to add comments on pages in this space.": "Consenti agli utenti di aggiungere commenti alle pagine in questo spazio.",
"Toggle viewer comments": "Attiva/disattiva i commenti degli utenti",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Page permissions": "Autorizzazioni della pagina.",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> ti ha menzionato su una pagina",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> ti ha dato l'accesso di modifica a una pagina",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> ti ha dato l'accesso di visualizzazione a una pagina",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> ha aggiornato una pagina.",
"Watch page": "Segui pagina",
"Stop watching": "Smetti di seguire",
"Email notifications": "Notifiche email",
"Page updates": "Aggiornamenti pagina",
"Get notified when pages you watch are updated.": "Ricevi una notifica quando le pagine che segui vengono aggiornate.",
"Page mentions": "Menzioni nella pagina",
"Get notified when someone mentions you on a page.": "Ricevi una notifica quando qualcuno ti menziona su una pagina.",
"Comment mentions": "Menzioni nei commenti",
"Get notified when someone mentions you in a comment.": "Ricevi una notifica quando qualcuno ti menziona in un commento.",
"New comments": "Nuovi commenti",
"Get notified about new comments on threads you participate in.": "Ricevi una notifica sui nuovi commenti nelle discussioni a cui partecipi.",
"Resolved comments": "Commenti risolti",
"Get notified when your comment is resolved.": "Ricevi una notifica quando il tuo commento viene risolto.",
"You are now watching this page": "Ora stai seguendo questa pagina",
"You are no longer watching this page": "Non stai più seguendo questa pagina",
"Direct": "Diretto",
"Updates": "Aggiornamenti",
"Today": "Oggi",
"Yesterday": "Ieri",
"This week": "Questa settimana",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
@@ -351,6 +352,12 @@
"Divider": "区切り線",
"Quote": "引用",
"Image": "画像",
"Audio": "音声。",
"Embed PDF": "PDFを埋め込む",
"Upload and embed a PDF file.": "PDFファイルをアップロードして埋め込みます。",
"Embed as PDF": "PDFとして埋め込む",
"Failed to load PDF": "PDFの読み込みに失敗しました",
"Convert to attachment": "添付ファイルに変換",
"File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える",
"Allow viewers to comment": "閲覧者によるコメントを許可",
"Allow viewers to add comments on pages in this space.": "このスペース内のページに閲覧者がコメントを追加できるようにします。",
"Toggle viewer comments": "閲覧者コメントの切り替え",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Page permissions": "ページのアクセス権",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold>さんがページであなたに言及しました",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold>さんがページの編集権限をあなたに付与しました",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold>さんがページの閲覧権限をあなたに付与しました",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold>さんがページを更新しました。",
"Watch page": "ページをウォッチ",
"Stop watching": "ウォッチを解除",
"Email notifications": "メール通知",
"Page updates": "ページの更新",
"Get notified when pages you watch are updated.": "ウォッチしているページが更新されたときに通知を受け取ります。",
"Page mentions": "ページでの言及",
"Get notified when someone mentions you on a page.": "誰かがページであなたに言及したとき通知を受け取ります。",
"Comment mentions": "コメントでの言及",
"Get notified when someone mentions you in a comment.": "誰かがコメントであなたに言及したとき通知を受け取ります。",
"New comments": "新しいコメント",
"Get notified about new comments on threads you participate in.": "参加しているスレッドに新しいコメントがあると通知されます。",
"Resolved comments": "解決済みコメント",
"Get notified when your comment is resolved.": "あなたのコメントが解決されたとき通知を受け取ります。",
"You are now watching this page": "このページをウォッチしています",
"You are no longer watching this page": "このページのウォッチを解除しました",
"Direct": "直接",
"Updates": "アップデート",
"Today": "今日",
"Yesterday": "昨日",
"This week": "今週",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "가로 구분선 삽입",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
@@ -351,6 +352,12 @@
"Divider": "구분선",
"Quote": "인용",
"Image": "이미지",
"Audio": "오디오.",
"Embed PDF": "PDF 임베드",
"Upload and embed a PDF file.": "PDF 파일을 업로드하고 임베드하세요.",
"Embed as PDF": "PDF로 임베드",
"Failed to load PDF": "PDF 로드 실패",
"Convert to attachment": "첨부 파일로 변환",
"File attachment": "파일 첨부",
"Toggle block": "블록 토글",
"Callout": "경고 상자",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
"Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
"Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
"Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Page permissions": "페이지 권한},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold>님이 페이지에서 당신을 언급했습니다",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold>님이 페이지 편집 권한을 부여했습니다",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold>님이 페이지 조회 권한을 부여했습니다",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold>님이 페이지를 업데이트했습니다.",
"Watch page": "페이지 구독",
"Stop watching": "구독 취소",
"Email notifications": "이메일 알림",
"Page updates": "페이지 업데이트",
"Get notified when pages you watch are updated.": "구독한 페이지가 업데이트될 때 알림을 받으세요.",
"Page mentions": "페이지 언급",
"Get notified when someone mentions you on a page.": "누군가가 페이지에서 당신을 언급하면 알림을 받으세요.",
"Comment mentions": "댓글 언급",
"Get notified when someone mentions you in a comment.": "누군가가 댓글에서 당신을 언급하면 알림을 받으세요.",
"New comments": "새 댓글",
"Get notified about new comments on threads you participate in.": "참여 중인 스레드에 새 댓글이 달리면 알림을 받으세요.",
"Resolved comments": "해결된 댓글",
"Get notified when your comment is resolved.": "내 댓글이 해결되었을 때 알림을 받으세요.",
"You are now watching this page": "이제 이 페이지를 주시합니다.",
"You are no longer watching this page": "더 이상 이 페이지를 주시하지 않습니다.",
"Direct": "직접",
"Updates": "업데이트",
"Today": "오늘",
"Yesterday": "어제",
"This week": "이번 주",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
@@ -351,6 +352,12 @@
"Divider": "Scheidingslijn",
"Quote": "Quote",
"Image": "Afbeelding",
"Audio": "Audio.",
"Embed PDF": "PDF insluiten",
"Upload and embed a PDF file.": "Upload en sluit een PDF-bestand in.",
"Embed as PDF": "Insluiten als PDF",
"Failed to load PDF": "Laden van PDF mislukt",
"Convert to attachment": "Converteren naar bijlage",
"File attachment": "Bestand bijlage",
"Toggle block": "Schakel blok in/uit",
"Callout": "Opmerking",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
"Allow viewers to comment": "Toestaan dat kijkers reageren",
"Allow viewers to add comments on pages in this space.": "Sta kijkers toe om reacties toe te voegen op pagina\u0019s in deze ruimte.",
"Toggle viewer comments": "Reacties van kijkers in- of uitschakelen",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Page permissions": "Pagina rechten",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> noemde je op een pagina",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> heeft je toegang gegeven om een pagina te bewerken",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> heeft je toegang gegeven om een pagina te bekijken",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> heeft een pagina bijgewerkt.",
"Watch page": "Pagina volgen",
"Stop watching": "Volgen stoppen",
"Email notifications": "E-mailmeldingen",
"Page updates": "Pagina-updates",
"Get notified when pages you watch are updated.": "Ontvang een melding wanneer pagina's die je volgt worden bijgewerkt.",
"Page mentions": "Pagina-vermeldingen",
"Get notified when someone mentions you on a page.": "Ontvang een melding wanneer iemand je noemt op een pagina.",
"Comment mentions": "Vermeldingen in opmerkingen",
"Get notified when someone mentions you in a comment.": "Ontvang een melding wanneer iemand je noemt in een opmerking.",
"New comments": "Nieuwe opmerkingen",
"Get notified about new comments on threads you participate in.": "Ontvang meldingen over nieuwe reacties in threads waaraan je deelneemt.",
"Resolved comments": "Opgeloste opmerkingen",
"Get notified when your comment is resolved.": "Ontvang een melding wanneer je reactie is opgelost.",
"You are now watching this page": "Je volgt nu deze pagina",
"You are no longer watching this page": "Je volgt deze pagina niet meer",
"Direct": "Direct",
"Updates": "Updates",
"Today": "Vandaag",
"Yesterday": "Gisteren",
"This week": "Deze week",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Citação",
"Image": "Imagem",
"Audio": "Áudio.",
"Embed PDF": "Incorporar PDF",
"Upload and embed a PDF file.": "Envie e incorpore um arquivo PDF.",
"Embed as PDF": "Incorporar como PDF",
"Failed to load PDF": "Falha ao carregar PDF",
"Convert to attachment": "Converter em anexo",
"File attachment": "Anexo de arquivo",
"Toggle block": "Bloco colapsável",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Allow viewers to comment": "Permitir que os visualizadores comentem",
"Allow viewers to add comments on pages in this space.": "Permitir que os visualizadores adicionem comentários em páginas deste espaço.",
"Toggle viewer comments": "Ativar/desativar comentários de visualizadores",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Page permissions": "Permissões da página},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mencionou você em uma página",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> concedeu acesso de edição a uma página",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> concedeu acesso de visualização a uma página",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> atualizou uma página.",
"Watch page": "Observar página",
"Stop watching": "Parar de observar",
"Email notifications": "Notificações por e-mail",
"Page updates": "Atualizações da página",
"Get notified when pages you watch are updated.": "Receba notificações quando as páginas que você observa forem atualizadas.",
"Page mentions": "Menções na página",
"Get notified when someone mentions you on a page.": "Receba notificações quando alguém mencionar você em uma página.",
"Comment mentions": "Menções em comentários",
"Get notified when someone mentions you in a comment.": "Receba notificações quando alguém mencionar você em um comentário.",
"New comments": "Novos comentários",
"Get notified about new comments on threads you participate in.": "Receba notificações sobre novos comentários nas discussões em que você participa.",
"Resolved comments": "Comentários resolvidos",
"Get notified when your comment is resolved.": "Receba notificações quando seu comentário for resolvido.",
"You are now watching this page": "Agora você está observando esta página",
"You are no longer watching this page": "Você não está mais observando esta página",
"Direct": "Direto",
"Updates": "Atualizações",
"Today": "Hoje",
"Yesterday": "Ontem",
"This week": "Esta semana",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
@@ -351,6 +352,12 @@
"Divider": "Разделитель",
"Quote": "Цитата",
"Image": "Изображение",
"Audio": "Аудио.",
"Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF",
"Failed to load PDF": "Не удалось загрузить PDF",
"Convert to attachment": "Преобразовать в вложение",
"File attachment": "Прикрепленный файл",
"Toggle block": "Сворачиваемый блок",
"Callout": "Выноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства",
"Allow viewers to comment": "Разрешить зрителям комментировать",
"Allow viewers to add comments on pages in this space.": "Разрешить зрителям добавлять комментарии на страницах в этом пространстве.",
"Toggle viewer comments": "Переключить комментарии зрителей",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Page permissions": "Права доступа к странице},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> упомянул вас на странице",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> предоставил вам доступ для редактирования страницы",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> предоставил вам доступ к просмотру страницы",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> обновил страницу.",
"Watch page": "Следить за страницей",
"Stop watching": "Прекратить отслеживание",
"Email notifications": "Уведомления на email",
"Page updates": "Обновления страницы",
"Get notified when pages you watch are updated.": "Получайте уведомления, когда отслеживаемые вами страницы обновляются.",
"Page mentions": "Упоминания на странице",
"Get notified when someone mentions you on a page.": "Получайте уведомления, когда кто-то упоминает вас на странице.",
"Comment mentions": "Упоминания в комментариях",
"Get notified when someone mentions you in a comment.": "Получайте уведомления, когда кто-то упоминает вас в комментарии.",
"New comments": "Новые комментарии",
"Get notified about new comments on threads you participate in.": "Получайте уведомления о новых комментариях в цепочках, в которых вы участвуете.",
"Resolved comments": "Разрешённые комментарии",
"Get notified when your comment is resolved.": "Получайте уведомление, когда ваш комментарий разрешён.",
"You are now watching this page": "Вы теперь следите за этой страницей",
"You are no longer watching this page": "Вы больше не следите за этой страницей",
"Direct": "Прямые",
"Updates": "Обновления",
"Today": "Сегодня",
"Yesterday": "Вчера",
"This week": "На этой неделе",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
@@ -351,6 +352,12 @@
"Divider": "Роздільник",
"Quote": "Цитата",
"Image": "Зображення",
"Audio": "Аудіо.",
"Embed PDF": "Вбудувати PDF",
"Upload and embed a PDF file.": "Завантажте та вбудуйте файл PDF.",
"Embed as PDF": "Вбудувати як PDF",
"Failed to load PDF": "Не вдалося завантажити PDF",
"Convert to attachment": "Перетворити на вкладення",
"File attachment": "Прикріплений файл",
"Toggle block": "Блок, що згортається",
"Callout": "Виноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
"Allow viewers to comment": "Дозволити глядачам коментувати",
"Allow viewers to add comments on pages in this space.": "Дозволити глядачам додавати коментарі на сторінках у цьому просторі.",
"Toggle viewer comments": "Увімкнути або вимкнути коментарі глядачів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Page permissions": "Права доступу до сторінки.",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> згадав вас на сторінці",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> надав вам доступ до редагування сторінки",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> надав вам доступ до перегляду сторінки",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> оновив сторінку.",
"Watch page": "Стежити за сторінкою",
"Stop watching": "Припинити стежити",
"Email notifications": "Сповіщення електронною поштою",
"Page updates": "Оновлення сторінки",
"Get notified when pages you watch are updated.": "Отримуйте сповіщення, коли сторінки, за якими ви стежите, оновлюються.",
"Page mentions": "Згадки на сторінці",
"Get notified when someone mentions you on a page.": "Отримуйте сповіщення, коли хтось згадує вас на сторінці.",
"Comment mentions": "Згадки у коментарях",
"Get notified when someone mentions you in a comment.": "Отримуйте сповіщення, коли хтось згадує вас у коментарі.",
"New comments": "Нові коментарі",
"Get notified about new comments on threads you participate in.": "Отримуйте сповіщення про нові коментарі у темах, у яких ви берете участь.",
"Resolved comments": "Вирішені коментарі",
"Get notified when your comment is resolved.": "Отримайте сповіщення, коли ваш коментар вирішено.",
"You are now watching this page": "Ви зараз стежите за цією сторінкою",
"You are no longer watching this page": "Ви більше не стежите за цією сторінкою",
"Direct": "Прямі",
"Updates": "Оновлення",
"Today": "Сьогодні",
"Yesterday": "Вчора",
"This week": "Цього тижня",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "插入水平分割线",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
"Upload any audio from your device.": "从您的设备上传任意音频文件。",
"Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
@@ -351,6 +352,12 @@
"Divider": "分割线",
"Quote": "引用",
"Image": "图像",
"Audio": "音频。",
"Embed PDF": "嵌入 PDF",
"Upload and embed a PDF file.": "上传并嵌入 PDF 文件。",
"Embed as PDF": "作为 PDF 嵌入",
"Failed to load PDF": "加载 PDF 失败",
"Convert to attachment": "转换为附件",
"File attachment": "文件附件",
"Toggle block": "切换块",
"Callout": "标注块",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享",
"Allow viewers to comment": "允许观众评论",
"Allow viewers to add comments on pages in this space.": "允许观众在此空间的页面上添加评论。",
"Toggle viewer comments": "切换观众评论",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Page permissions": "页面权限},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold>在页面上提到你",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold>授予你页面编辑权限",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold>授予你页面查看权限",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold>更新了一个页面。",
"Watch page": "关注页面",
"Stop watching": "取消关注",
"Email notifications": "邮件通知",
"Page updates": "页面更新",
"Get notified when pages you watch are updated.": "当你关注的页面有更新时收到通知。",
"Page mentions": "页面提及",
"Get notified when someone mentions you on a page.": "当有人在页面上提到你时收到通知。",
"Comment mentions": "评论提及",
"Get notified when someone mentions you in a comment.": "当有人在评论中提到你时收到通知。",
"New comments": "新评论",
"Get notified about new comments on threads you participate in.": "当你参与的讨论有新评论时收到通知。",
"Resolved comments": "已解决的评论",
"Get notified when your comment is resolved.": "当你的评论被解决时收到通知。",
"You are now watching this page": "你现在正在关注此页面",
"You are no longer watching this page": "你已取消关注此页面",
"Direct": "直接",
"Updates": "更新",
"Today": "今天",
"Yesterday": "昨天",
"This week": "本周",
+12
View File
@@ -38,6 +38,10 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import TemplateList from "@/ee/template/pages/template-list";
import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
export default function App() {
@@ -81,7 +85,15 @@ export default function App() {
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
<Route path={"/ai"} element={<AiChat />} />
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
element={<TemplateEditor />}
/>
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
+5 -2
View File
@@ -1,4 +1,4 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
@@ -6,8 +6,10 @@ import { useTranslation } from "react-i18next";
interface CopyProps {
text: string;
size?: MantineSize;
color?: MantineColor;
}
export default function CopyTextButton({ text }: CopyProps) {
export default function CopyTextButton({ text, size }: CopyProps) {
const { t } = useTranslation();
return (
@@ -22,6 +24,7 @@ export default function CopyTextButton({ text }: CopyProps) {
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
size={size}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
@@ -5,6 +5,7 @@ import {
Badge,
Table,
ActionIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
@@ -23,7 +24,8 @@ interface Props {
export default function RecentChanges({ spaceId }: Props) {
const { t } = useTranslation();
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
const pages = data?.pages.flatMap((p) => p.items) ?? [];
if (isLoading) {
return <PageListSkeleton />;
@@ -33,58 +35,72 @@ export default function RecentChanges({ spaceId }: Props) {
return <Text>{t("Failed to fetch recent pages")}</Text>;
}
return pages && pages.items.length > 0 ? (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.items.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<UnstyledButton
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
</Table.Td>
{!spaceId && (
return pages.length > 0 ? (
<>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
<UnstyledButton
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
{page?.space.name}
</Badge>
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{!spaceId && (
<Table.Td>
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
>
{page?.space.name}
</Badge>
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{hasNextPage && (
<Button
variant="subtle"
fullWidth
mt="sm"
mb="xl"
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
>
{t("Load more")}
</Button>
)}
</>
) : (
<EmptyState
icon={IconFiles}
@@ -7,6 +7,19 @@
padding-right: var(--mantine-spacing-md);
}
.brand {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.brandIcon {
display: flex;
align-items: center;
}
.link {
display: block;
line-height: 1;
@@ -16,6 +29,9 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
user-select: none;
white-space: nowrap;
flex-shrink: 0;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
@@ -1,8 +1,18 @@
import { Badge, Group, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Badge,
Box,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { IconSparkles } from "@tabler/icons-react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai";
import {
@@ -23,8 +33,11 @@ import {
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
const links = [
{ link: APP_ROUTE.HOME, label: "Home" },
];
export function AppHeader() {
const { t } = useTranslation();
@@ -34,10 +47,12 @@ export function AppHeader() {
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const { isTrial, trialDaysLeft } = useTrial();
const location = useLocation();
const toggleAside = useToggleAside();
const [workspace] = useAtom(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const hideSidebar = isHomeRoute || isSpacesRoute;
const isPageRoute = location.pathname.includes("/p/");
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
@@ -49,39 +64,44 @@ export function AppHeader() {
<>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
<Group wrap="nowrap">
{!hideSidebar && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
<Text
size="lg"
fw={600}
style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
>
Docmost
</Text>
<Link to="/home" className={classes.brand} aria-label="Docmost">
<Box hiddenFrom="sm" className={classes.brandIcon}>
<img
src="/icons/favicon-32x32.png"
alt="Docmost"
width={22}
height={22}
/>
</Box>
<Text
size="lg"
fw={600}
style={{ userSelect: "none" }}
visibleFrom="sm"
>
Docmost
</Text>
</Link>
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
{items}
@@ -98,6 +118,49 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
{aiChatEnabled && (
<>
<UnstyledButton
component={Link}
to="/ai"
className={classes.link}
visibleFrom="sm"
onClick={(e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
if (isPageRoute) {
e.preventDefault();
toggleAside("chat");
}
}}
>
{t("AI Chat")}
</UnstyledButton>
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="dark"
size="sm"
hiddenFrom="sm"
aria-label={t("AI Chat")}
onClick={(e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
if (isPageRoute) {
e.preventDefault();
toggleAside("chat");
}
}}
>
<IconSparkles size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
)}
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
@@ -25,6 +26,10 @@ export default function Aside() {
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
case "chat":
component = <AsideChatPanel />;
title = "AI Chat";
break;
default:
component = null;
title = null;
@@ -34,12 +39,14 @@ export default function Aside() {
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (
<>
<Text mb="md" fw={500}>
{t(title)}
</Text>
{tab !== "chat" && (
<Text mb="md" fw={500}>
{t(title)}
</Text>
)}
{tab === "comments" ? (
<CommentListWithTabs />
{tab === "comments" || tab === "chat" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
@@ -10,11 +10,13 @@ import {
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
export default function GlobalAppShell({
children,
@@ -72,24 +74,21 @@ export default function GlobalAppShell({
const location = useLocation();
const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/");
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const isAiRoute = location.pathname.startsWith("/ai");
const isPageRoute = location.pathname.includes("/p/");
const hideSidebar = isHomeRoute || isSpacesRoute;
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return (
<AppShell
header={{ height: 45 }}
navbar={
!hideSidebar && {
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}
}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}}
aside={
isPageRoute && {
width: 350,
@@ -102,20 +101,22 @@ export default function GlobalAppShell({
<AppShell.Header px="md" className={classes.header}>
<AppHeader />
</AppShell.Header>
{!hideSidebar && (
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
>
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
>
{isSpaceRoute && (
<div className={classes.resizeHandle} onMouseDown={startResizing} />
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
</AppShell.Navbar>
)}
)}
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
{isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main>
{isSettingsRoute ? (
<Container size={850}>{children}</Container>
<Container size={900}>{children}</Container>
) : (
children
)}
@@ -0,0 +1,89 @@
.navbar {
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
}
.section {
padding-bottom: var(--mantine-spacing-xs);
}
.link {
cursor: pointer;
display: flex;
align-items: center;
text-decoration: none;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
padding-left: var(--mantine-spacing-xs);
min-height: 30px;
border-radius: var(--mantine-radius-sm);
font-weight: 500;
user-select: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
&[data-active] {
&,
& :hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
}
.linkIcon {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-right: var(--mantine-spacing-sm);
width: rem(16px);
height: rem(16px);
}
.sectionHeader {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.spacer {
flex: 1;
}
.bottomSection {
padding-top: var(--mantine-spacing-xs);
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.spaceItem {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
text-decoration: none;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
padding-left: var(--mantine-spacing-xs);
min-height: 30px;
border-radius: var(--mantine-radius-sm);
font-weight: 500;
user-select: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
@@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
import {
IconHome,
IconClock,
IconStar,
IconLayoutGrid,
IconSettings,
IconUserPlus,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
import { getSpaceUrl } from "@/lib/config";
import { useDisclosure } from "@mantine/hooks";
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
];
export default function GlobalSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { data: favoriteSpacesData } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces]
.filter((fav) => fav.space)
.sort((a, b) => {
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
});
const [inviteOpened, { open: openInvite, close: closeInvite }] =
useDisclosure(false);
useEffect(() => {
setActive(location.pathname);
}, [location.pathname]);
const handleNavClick = () => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
};
return (
<div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}>
{mainNavItems.map((item) => (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
))}
</div>
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
{sortedFavoriteSpaces.length === 0 ? (
<Text size="xs" c="dimmed" pl="xs" py={4}>
{t("Favorite spaces appear here")}
</Text>
) : (
<>
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
<Link
key={fav.id}
className={classes.spaceItem}
to={getSpaceUrl(fav.space!.slug)}
onClick={handleNavClick}
>
<CustomAvatar
name={fav.space!.name}
avatarUrl={fav.space!.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<Text size="sm" fw={500} lineClamp={1}>
{fav.space!.name}
</Text>
</Link>
))}
{sortedFavoriteSpaces.length > 10 && (
<Link
className={classes.spaceItem}
to="/spaces"
onClick={handleNavClick}
>
<Text size="xs" c="dimmed">
{t("View all")}
</Text>
</Link>
)}
</>
)}
</div>
</ScrollArea>
<div className={classes.bottomSection}>
<a
className={classes.link}
onClick={(e) => {
e.preventDefault();
openInvite();
}}
href="#"
>
<IconUserPlus className={classes.linkIcon} stroke={2} />
<span>{t("Invite People")}</span>
</a>
<Link
className={classes.link}
data-active={active.startsWith("/settings") || undefined}
to="/settings/account/profile"
onClick={handleNavClick}
>
<IconSettings className={classes.linkIcon} stroke={2} />
<span>{t("Settings")}</span>
</Link>
</div>
<Modal
size="550"
opened={inviteOpened}
onClose={closeInvite}
title={t("Invite new members")}
centered
>
<Divider size="xs" mb="xs" />
<ScrollArea h="80%">
<WorkspaceInviteForm onClose={closeInvite} />
</ScrollArea>
</Modal>
</div>
);
}
@@ -0,0 +1,68 @@
.root {
position: relative;
}
.track {
display: flex;
gap: var(--mantine-spacing-md);
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 2px;
margin: -2px;
}
.track::-webkit-scrollbar {
display: none;
}
.track > * {
scroll-snap-align: start;
flex: 0 0 auto;
}
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
cursor: pointer;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
z-index: 2;
}
.root:hover .arrow.visible,
.arrow.visible:focus-visible {
opacity: 1;
pointer-events: auto;
}
.arrow:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.arrow:active {
transform: translateY(-50%) scale(0.95);
}
.arrowLeft {
left: -14px;
}
.arrowRight {
right: -14px;
}
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./card-carousel.module.css";
type Props = {
children: ReactNode;
ariaLabel?: string;
};
export default function CardCarousel({ children, ariaLabel }: Props) {
const { t } = useTranslation();
const trackRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const el = trackRef.current;
if (!el) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft < maxScroll - 1);
}, []);
useEffect(() => {
updateScrollState();
const el = trackRef.current;
if (!el) return;
const observer = new ResizeObserver(updateScrollState);
observer.observe(el);
for (const child of Array.from(el.children)) {
observer.observe(child);
}
return () => observer.disconnect();
}, [updateScrollState, children]);
const scrollBy = (direction: 1 | -1) => {
const el = trackRef.current;
if (!el) return;
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
};
return (
<div className={classes.root}>
<div
ref={trackRef}
className={classes.track}
onScroll={updateScrollState}
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
>
{children}
</div>
<button
type="button"
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
onClick={() => scrollBy(-1)}
aria-label={t("Scroll left")}
tabIndex={canScrollLeft ? 0 : -1}
>
<IconChevronLeft size={18} />
</button>
<button
type="button"
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
onClick={() => scrollBy(1)}
aria-label={t("Scroll right")}
tabIndex={canScrollRight ? 0 : -1}
>
<IconChevronRight size={18} />
</button>
</div>
);
}
@@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import { Modal, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DestinationPicker } from "./destination-picker";
import {
DestinationPickerModalProps,
DestinationSelection,
} from "./destination-picker.types";
export function DestinationPickerModal({
opened,
onClose,
title,
actionLabel,
onSelect,
loading,
excludePageId,
pageLimit,
}: DestinationPickerModalProps) {
const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null);
useEffect(() => {
if (!opened) {
setSelection(null);
}
}, [opened]);
return (
<Modal.Root
opened={opened}
onClose={onClose}
size={550}
padding="lg"
yOffset="10vh"
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content>
<Modal.Header py={0}>
<Modal.Title fw={500}>{title}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<DestinationPicker
onSelectionChange={setSelection}
excludePageId={excludePageId}
pageLimit={pageLimit}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
<Button
onClick={() => selection && onSelect(selection)}
disabled={!selection}
loading={loading}
>
{actionLabel}
</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -0,0 +1,128 @@
.searchInput {
margin-bottom: var(--mantine-spacing-sm);
}
.scrollArea {
max-height: 50vh;
}
.row {
padding: 6px 12px;
border-radius: var(--mantine-radius-sm);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 150ms ease;
user-select: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
}
.selected {
background-color: light-dark(
var(--mantine-color-blue-0),
var(--mantine-color-dark-5)
);
border-left: 2px solid var(--mantine-primary-color-filled);
}
.spaceRow {
composes: row;
font-weight: 500;
}
.pageRow {
composes: row;
font-weight: 400;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.chevron {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-sm);
flex-shrink: 0;
transition: transform 150ms ease;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
}
.chevronExpanded {
transform: rotate(90deg);
}
.loadMore {
text-align: center;
padding: 6px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-sm);
cursor: pointer;
@mixin hover {
text-decoration: underline;
}
}
.selectedIndicator {
padding: 8px 12px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: var(--mantine-spacing-xs);
}
.emptyState {
padding: 12px;
text-align: center;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.searchResult {
composes: row;
}
.pageTitle {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--mantine-font-size-sm);
}
.spaceName {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
}
.iconWrapper {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
line-height: 1;
}
@@ -0,0 +1,168 @@
import { useState, useCallback } from "react";
import { TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch, IconFile } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
import { ISpace } from "@/features/space/types/space.types";
import { IPage } from "@/features/page/types/page.types";
import { DestinationSelection } from "./destination-picker.types";
import { SpaceRow } from "./space-row";
import classes from "./destination-picker.module.css";
type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string;
pageLimit?: number;
};
export function DestinationPicker({
onSelectionChange,
excludePageId,
pageLimit = 15,
}: DestinationPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100,
});
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({
query: searchEnabled ? debouncedQuery : "",
includePages: true,
limit: 20,
});
const isSearching = !!searchEnabled;
const selectedId =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
const updateSelection = useCallback(
(next: DestinationSelection | null) => {
setSelection(next);
onSelectionChange(next);
},
[onSelectionChange],
);
const handleSearchResultClick = (page: Partial<IPage>) => {
if (!page.space || !page.id) return;
updateSelection({
type: "page",
spaceId: page.space.id,
pageId: page.id,
page,
space: page.space,
});
setSearchQuery("");
};
const handleSelectSpace = useCallback(
(space: ISpace) => {
updateSelection({ type: "space", spaceId: space.id, space });
},
[updateSelection],
);
const handleSelectPage = useCallback(
(page: Partial<IPage>, space: ISpace) => {
if (!page.id) return;
updateSelection({
type: "page",
spaceId: page.spaceId ?? space.id,
pageId: page.id,
page,
space,
});
},
[updateSelection],
);
return (
<>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search pages and spaces...")}
variant="filled"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput}
/>
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
{isSearching ? (
searchLoading ? (
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : searchData?.pages && searchData.pages.length > 0 ? (
searchData.pages.map(
(page) =>
page && (
<div
key={page.id}
className={classes.searchResult}
onClick={() => handleSearchResultClick(page)}
>
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</div>
<div className={classes.pageTitle}>
{page.title || t("Untitled")}
</div>
{page.space && (
<div className={classes.spaceName}>
{page.space.name}
</div>
)}
</div>
),
)
) : (
<div className={classes.emptyState}>{t("No results found")}</div>
)
) : spacesLoading ? (
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : (
spacesData?.items?.map((space) => (
<SpaceRow
key={space.id}
space={space}
limit={pageLimit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelectSpace={handleSelectSpace}
onSelectPage={handleSelectPage}
/>
))
)}
</ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</>
);
}
@@ -0,0 +1,23 @@
import { ISpace } from "@/features/space/types/space.types";
import { IPage } from "@/features/page/types/page.types";
export type DestinationSelection =
| { type: "space"; spaceId: string; space: ISpace }
| {
type: "page";
spaceId: string;
pageId: string;
page: Partial<IPage>;
space: Partial<ISpace>;
};
export type DestinationPickerModalProps = {
opened: boolean;
onClose: () => void;
title: string;
actionLabel: string;
onSelect: (selection: DestinationSelection) => void | Promise<void>;
loading?: boolean;
excludePageId?: string;
pageLimit?: number;
};
@@ -0,0 +1,83 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { getSidebarPages } from "@/features/page/services/page-service";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types";
import { PageRow } from "./page-row";
import classes from "./destination-picker.module.css";
type PageChildrenProps = {
spaceId: string;
pageId?: string;
depth: number;
limit: number;
selectedId: string | null;
excludePageId?: string;
onSelectPage: (page: Partial<IPage>) => void;
};
export function PageChildren({
spaceId,
pageId,
depth,
limit,
selectedId,
excludePageId,
onSelectPage,
}: PageChildrenProps) {
const { t } = useTranslation();
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
queryFn: ({ pageParam }) =>
getSidebarPages({
spaceId,
pageId,
limit,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: IPagination<IPage>) =>
lastPage.meta?.nextCursor ?? undefined,
});
const pages = data?.pages.flatMap((page) => page.items) ?? [];
if (isLoading) {
return (
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
);
}
if (pages.length === 0) {
return (
<div className={classes.emptyState}>
{pageId ? t("No pages inside") : t("No pages in this space")}
</div>
);
}
return (
<>
{pages.map((page) => (
<PageRow
key={page.id}
page={page}
depth={depth}
limit={limit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelect={onSelectPage}
/>
))}
{hasNextPage && (
<div className={classes.loadMore} onClick={() => fetchNextPage()}>
{t("Load more")}
</div>
)}
</>
);
}
@@ -0,0 +1,89 @@
import { useState } from "react";
import { IconChevronRight, IconFile } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
type PageRowProps = {
page: Partial<IPage>;
depth: number;
limit: number;
selectedId: string | null;
excludePageId?: string;
onSelect: (page: Partial<IPage>) => void;
};
export function PageRow({
page,
depth,
limit,
selectedId,
excludePageId,
onSelect,
}: PageRowProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const isExcluded = page.id === excludePageId;
const isSelected = page.id === selectedId;
const rowClasses = [
classes.pageRow,
isSelected && classes.selected,
isExcluded && classes.disabled,
]
.filter(Boolean)
.join(" ");
return (
<>
<div
className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }}
onClick={() => !isExcluded && onSelect(page)}
>
{page.hasChildren ? (
<div
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</div>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</div>
<div className={classes.pageTitle}>
{page.title || t("Untitled")}
</div>
</div>
{expanded && page.hasChildren && (
<PageChildren
spaceId={page.spaceId}
pageId={page.id}
depth={depth + 1}
limit={limit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelectPage={onSelect}
/>
)}
</>
);
}
@@ -0,0 +1,108 @@
import { useState } from "react";
import { Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types";
import { IPage } from "@/features/page/types/page.types";
import { SpaceRole } from "@/lib/types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
type SpaceRowProps = {
space: ISpace;
limit: number;
selectedId: string | null;
excludePageId?: string;
onSelectSpace: (space: ISpace) => void;
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
};
export function SpaceRow({
space,
limit,
selectedId,
excludePageId,
onSelectSpace,
onSelectPage,
}: SpaceRowProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const writable =
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
const isSelected = space.id === selectedId;
const rowClasses = [
classes.spaceRow,
isSelected && classes.selected,
!writable && classes.disabled,
]
.filter(Boolean)
.join(" ");
const rowContent = (
<div
className={rowClasses}
onClick={() => writable && onSelectSpace(space)}
>
{writable ? (
<div
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</div>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
size={22}
/>
<div className={classes.pageTitle}>{space.name}</div>
{!writable && (
<IconLock
size={14}
color="var(--mantine-color-gray-5)"
/>
)}
</div>
);
return (
<>
{writable ? (
rowContent
) : (
<Tooltip
label={t("You don't have permission to create pages here")}
position="right"
withArrow
>
<div>{rowContent}</div>
</Tooltip>
)}
{expanded && writable && (
<PageChildren
spaceId={space.id}
depth={1}
limit={limit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelectPage={(page) => onSelectPage(page, space)}
/>
)}
</>
);
}
@@ -0,0 +1,106 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import { useChatStream } from "../hooks/use-chat-stream";
import ChatMessageList from "./chat-message-list";
import ChatEmptyState from "./chat-empty-state";
import ChatInput from "./chat-input";
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
import classes from "../styles/ai-chat.module.css";
export default function AiChatLayout() {
const { chatId } = useParams<{ chatId: string }>();
const location = useLocation();
const navigate = useNavigate();
const chatInfoQuery = useChatInfoQuery(chatId);
// If the URL points at a chat the user does not own, the info fetch 404s.
// Bounce them back to /ai so they cannot interact with any chat UI (including
// kicking off orphan uploads) tied to a chat they have no access to.
useEffect(() => {
if (chatId && chatInfoQuery.isError) {
navigate("/ai", { replace: true });
}
}, [chatId, chatInfoQuery.isError, navigate]);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId);
const autoSentRef = useRef(false);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
useEffect(() => {
if (autoSentRef.current || chatId) return;
const state = location.state as HomeAiPromptInitialState | null;
if (!state?.initialContent && !state?.initialAttachments?.length) return;
autoSentRef.current = true;
sendMessage(
state.initialContent ?? "",
state.initialMentions ?? [],
state.initialAttachments ?? [],
);
navigate(location.pathname, { replace: true, state: null });
}, [chatId, location, navigate, sendMessage]);
const hasMessages = messages.length > 0 || isStreaming;
// While the redirect effect is running (or if the user is still on this
// component for any reason) never render the chat UI for a forbidden chat.
if (chatId && chatInfoQuery.isError) {
return null;
}
return (
<div className={classes.main}>
{error && (
<div
style={{
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-sm)",
}}
>
{error}
</div>
)}
{hasMessages ? (
<>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
chatId={chatId}
/>
</div>
</>
) : (
<ChatEmptyState
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
/>
)}
</div>
);
}
@@ -0,0 +1,166 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { ActionIcon, Menu, TextInput } from "@mantine/core";
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { AiChat } from "../types/ai-chat.types";
import classes from "../styles/chat-sidebar.module.css";
type Props = {
chat: AiChat;
isActive: boolean;
onDelete: (chatId: string) => void;
onRename: (chatId: string, title: string) => void;
};
function formatChatDate(
isoString: string | Date,
locale: string | undefined,
): string {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const ts = date.getTime();
const sameYear = date.getFullYear() === now.getFullYear();
if (ts >= startOfToday) {
return date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
});
}
if (sameYear) {
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
}
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export default function AiChatSidebarItem({
chat,
isActive,
onDelete,
onRename,
}: Props) {
const { t, i18n } = useTranslation();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const formattedDate = useMemo(
() => formatChatDate(chat.updatedAt, i18n.language),
[chat.updatedAt, i18n.language],
);
useEffect(() => {
if (renaming) {
// Wait for the input to be mounted before selecting.
const id = window.setTimeout(() => inputRef.current?.select(), 0);
return () => window.clearTimeout(id);
}
}, [renaming]);
const startRename = useCallback(() => {
setRenameValue(chat.title || "");
setRenaming(true);
}, [chat.title]);
const submitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== chat.title) {
onRename(chat.id, trimmed);
}
setRenaming(false);
}, [renameValue, chat.id, chat.title, onRename]);
if (renaming) {
return (
<div className={classes.chatItem} data-active={isActive || undefined}>
<TextInput
ref={inputRef}
size="xs"
variant="unstyled"
placeholder={t("Chat name")}
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitRename();
} else if (e.key === "Escape") {
e.preventDefault();
setRenaming(false);
}
}}
onBlur={submitRename}
classNames={{ input: classes.chatItemRenameInput }}
style={{ flex: 1 }}
/>
</div>
);
}
return (
<Link
to={`/ai/chat/${chat.id}`}
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename();
}}
>
{t("Rename")}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(chat.id);
}}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</Link>
);
}
@@ -0,0 +1,180 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useChatsQuery,
useDeleteChatMutation,
useUpdateChatTitleMutation,
useSearchChatsQuery,
} from "../queries/ai-chat-query";
import AiChatSidebarItem from "./ai-chat-sidebar-item";
import { groupChatsByAge } from "../utils/group-chats-by-age";
import classes from "../styles/chat-sidebar.module.css";
export default function AiChatSidebar() {
const { t } = useTranslation();
const navigate = useNavigate();
const { chatId } = useParams<{ chatId: string }>();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const chatsQuery = useChatsQuery();
const searchQuery = useSearchChatsQuery(debouncedSearch);
const deleteMutation = useDeleteChatMutation();
const renameMutation = useUpdateChatTitleMutation();
const chats = useMemo(() => {
if (debouncedSearch) {
return searchQuery.data || [];
}
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
const sentinelRef = useRef<HTMLDivElement>(null);
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
const isSearching = Boolean(debouncedSearch);
useEffect(() => {
if (isSearching) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
navigate("/ai");
},
[navigate],
);
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
},
[deleteMutation, chatId, navigate],
);
const handleRename = useCallback(
(chatId: string, title: string) => {
renameMutation.mutate({ chatId, title });
},
[renameMutation],
);
const isLoading = chatsQuery.isLoading || searchQuery.isLoading;
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<span className={classes.title}>{t("AI Chat")}</span>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("New chat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
</div>
<TextInput
className={classes.searchInput}
placeholder="Search chats..."
leftSection={<IconSearch size={14} />}
size="xs"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<div className={classes.chatList}>
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
{!isLoading && chats.length === 0 && (
<div className={classes.chatListEmpty}>
<IconMessageCircle2
size={28}
stroke={1.5}
className={classes.chatListEmptyIcon}
/>
<div className={classes.chatListEmptyTitle}>
{isSearching ? t("No chats found") : t("No conversations yet")}
</div>
<div className={classes.chatListEmptyHint}>
{isSearching
? t("Try a different search term.")
: t("Start a new chat to see it here.")}
</div>
</div>
)}
{isSearching
? chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<div className={classes.chatGroupLabel}>{group.label}</div>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))}
</div>
))}
{!isSearching && (
<>
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import classes from "../styles/aside-chat-panel.module.css";
type Props = {
activeChatId: string | undefined;
onSelect: (chatId: string) => void;
};
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const chatsQuery = useChatsQuery();
const searchQuery = useSearchChatsQuery(debouncedSearch);
const isSearching = debouncedSearch.length > 0;
const chats = isSearching
? (searchQuery.data ?? [])
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
return (
<div>
<TextInput
placeholder={t("Search chats...")}
leftSection={<IconSearch size={14} />}
size="xs"
mb="xs"
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
/>
{isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
<Loader size="sm" />
</div>
) : chats.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{isSearching ? t("No chats found") : t("No chat history")}
</Text>
) : (
<ScrollArea.Autosize mah={300} scrollbars="y">
<div className={classes.historyList}>
{chats.map((chat) => (
<div
key={chat.id}
className={classes.historyItem}
data-active={chat.id === activeChatId || undefined}
onClick={() => onSelect(chat.id)}
>
<span className={classes.historyItemTitle}>
{chat.title || t("Untitled chat")}
</span>
</div>
))}
</div>
</ScrollArea.Autosize>
)}
</div>
);
}
@@ -0,0 +1,258 @@
import { useState, useEffect, useCallback } from "react";
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
import {
IconPlus,
IconChevronDown,
IconArrowsDiagonal,
IconX,
IconSparkles,
IconFileText,
IconLanguage,
IconSearch,
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useChatStream } from "../hooks/use-chat-stream";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import ChatMessageList from "./chat-message-list";
import ChatInput from "./chat-input";
import AsideChatHistory from "./aside-chat-history";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/aside-chat-panel.module.css";
type QuickAction = {
icon: React.ReactNode;
label: string;
prompt: string;
};
export default function AsideChatPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const [, setAsideState] = useAtom(asideStateAtom);
const [chatId, setChatId] = useState<string | undefined>(undefined);
const [historyOpen, setHistoryOpen] = useState(false);
const [contextPages, setContextPages] = useState<PageMention[]>([]);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: slugId });
const chatInfoQuery = useChatInfoQuery(chatId);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId, {
onChatCreated: (newChatId) => {
setChatId(newChatId);
},
});
useEffect(() => {
if (page && !chatId) {
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
}
}, [page, chatId]);
const handleRemoveContextPage = useCallback((pageId: string) => {
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
}, []);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
// Drop the open chatId if the current user lost access to it (404/403 on
// the info fetch). Reverts the panel to a fresh chat instead of presenting
// an input tied to a chat the user does not own.
useEffect(() => {
if (chatId && chatInfoQuery.isError) {
setChatId(undefined);
}
}, [chatId, chatInfoQuery.isError]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
setChatId(undefined);
if (page) {
setContextPages([
{ id: page.id, title: page.title || "", slugId: page.slugId },
]);
}
},
[page],
);
const handleSelectChat = useCallback((selectedChatId: string) => {
setChatId(selectedChatId);
setHistoryOpen(false);
}, []);
const handleExpand = useCallback(() => {
if (chatId) {
navigate(`/ai/chat/${chatId}`);
} else {
navigate("/ai");
}
setAsideState({ tab: "", isAsideOpen: false });
}, [chatId, navigate, setAsideState]);
const handleClose = useCallback(() => {
setAsideState({ tab: "", isAsideOpen: false });
}, [setAsideState]);
const handleSend = useCallback(
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
sendMessage(content, mentions, attachments, contextPageId);
},
[sendMessage, contextPages],
);
const handleQuickAction = useCallback(
(prompt: string) => {
handleSend(prompt, [], []);
},
[handleSend],
);
const hasMessages = messages.length > 0 || isStreaming;
const quickActions: QuickAction[] = [
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
];
return (
<div className={classes.panel}>
<div className={classes.toolbar}>
<Popover
opened={historyOpen}
onChange={setHistoryOpen}
position="bottom-start"
width={280}
shadow="md"
>
<Popover.Target>
<UnstyledButton
className={classes.titleButton}
onClick={() => setHistoryOpen((o) => !o)}
>
<span className={classes.titleText}>
{chatInfoQuery.data?.chat?.title || t("New chat")}
</span>
<IconChevronDown size={16} stroke={1.75} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
</Popover.Dropdown>
</Popover>
<div className={classes.toolbarSpacer} />
<Tooltip label={t("New chat")} openDelay={250}>
<ActionIcon
component="a"
href="/ai"
variant="subtle"
color="dark"
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
</div>
{error && (
<div
style={{
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-xs)",
}}
>
{error}
</div>
)}
{hasMessages ? (
<>
<div className={classes.messages} data-aside-chat>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</div>
</>
) : (
<div className={classes.emptyState}>
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
<div className={classes.quickActions}>
{quickActions.map((action) => (
<button
key={action.label}
type="button"
className={classes.quickAction}
onClick={() => handleQuickAction(action.prompt)}
>
<span className={classes.quickActionIcon}>{action.icon}</span>
{action.label}
</button>
))}
</div>
</div>
)}
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={handleSend}
onStop={stopGeneration}
placeholder={t("Ask anything...")}
autofocus={false}
contextPages={contextPages}
onRemoveContextPage={handleRemoveContextPage}
variant="flat"
chatId={chatId}
/>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import {
IconSparkles,
IconSearch,
IconFilePlus,
IconEdit,
IconFileText,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css";
type Suggestion = {
icon: React.ReactNode;
text: string;
prompt: string;
};
const SUGGESTIONS: Suggestion[] = [
{
icon: <IconSearch size={16} />,
text: "Search across all pages",
prompt: "Search for pages about ",
},
{
icon: <IconFilePlus size={16} />,
text: "Create a new page",
prompt: "Create a new page titled ",
},
{
icon: <IconFileText size={16} />,
text: "Summarize a page",
prompt: "Summarize the page @",
},
{
icon: <IconEdit size={16} />,
text: "Update page content",
prompt: "Update the page @",
},
];
type Props = {
isStreaming: boolean;
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
onStop: () => void;
};
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation();
const handleSuggestionClick = (prompt: string) => {
onSend(prompt, [], []);
};
return (
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<div className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</div>
<div className={classes.emptyStateInput}>
<ChatInput
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
placeholder="Ask anything... Use @ to mention pages"
autofocus
/>
</div>
<div className={classes.suggestionsSection}>
<div className={classes.suggestionsLabel}>Get started</div>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
<button
key={s.text}
type="button"
className={classes.suggestionCard}
onClick={() => handleSuggestionClick(s.prompt)}
>
<span className={classes.suggestionIcon}>{s.icon}</span>
<span className={classes.suggestionText}>{s.text}</span>
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,409 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { CharacterCount } from "@tiptap/extensions";
import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
import { uploadChatFile } from "../services/ai-chat-service";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/chat-input.module.css";
type PendingAttachment = ChatAttachment & { uploading: boolean };
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp";
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
type Props = {
isStreaming: boolean;
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
onStop: () => void;
placeholder?: string;
autofocus?: boolean;
contextPages?: PageMention[];
onRemoveContextPage?: (pageId: string) => void;
variant?: "card" | "flat";
showDisclaimer?: boolean;
chatId?: string;
};
function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = [];
const seen = new Set<string>();
function walk(node: any) {
if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) {
if (!seen.has(node.attrs.entityId)) {
seen.add(node.attrs.entityId);
mentions.push({
id: node.attrs.entityId,
title: node.attrs.label || "",
slugId: node.attrs.slugId || "",
});
}
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return mentions;
}
function editorJsonToText(json: any): string {
let text = "";
function walk(node: any) {
if (node.type === "text") {
text += node.text || "";
} else if (node.type === "mention") {
text += `@${node.attrs?.label || ""}`;
} else if (node.type === "paragraph") {
if (text.length > 0) text += "\n";
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
return;
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return text;
}
export default function ChatInput({
isStreaming,
onSend,
onStop,
placeholder,
autofocus = true,
contextPages,
onRemoveContextPage,
variant = "card",
showDisclaimer = true,
chatId,
}: Props) {
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const { t } = useTranslation();
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
if (room <= 0) {
notifications.show({
color: "yellow",
message: t("You can attach up to {{max}} files per message.", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
}),
});
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
const incoming = Array.from(files);
const accepted = incoming.slice(0, room);
if (incoming.length > accepted.length) {
notifications.show({
color: "yellow",
message: t(
"Only the first {{n}} file(s) were added (max {{max}} per message).",
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
),
});
}
for (const file of accepted) {
const tempId = `uploading-${Date.now()}-${Math.random()}`;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const placeholder: PendingAttachment = {
id: tempId,
fileName: file.name,
fileExt: ext,
fileSize: file.size,
mimeType: file.type,
uploading: true,
};
setPendingAttachments((prev) => [...prev, placeholder]);
try {
const uploaded = await uploadChatFile(file, chatIdRef.current);
setPendingAttachments((prev) =>
prev.map((a) =>
a.id === tempId ? { ...uploaded, uploading: false } : a,
),
);
} catch {
setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId));
}
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [pendingAttachments.length, t]);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
if (!text && readyAttachments.length === 0) return;
const mentions = extractMentions(json);
onSendRef.current(text, mentions, readyAttachments);
editor.commands.clearContent();
editor.commands.focus();
setPendingAttachments([]);
}, [isStreaming, pendingAttachments]);
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const editor = useEditor({
extensions: [
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || "Ask anything... Use @ to mention pages",
}),
CharacterCount.configure({
limit: 50000,
}),
LinkExtension,
EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
],
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
event.key,
)
) {
const emojiCommand = document.querySelector("#emoji-command");
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmitRef.current();
return true;
}
},
},
},
content: "",
editable: true,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
onUpdate: ({ editor: e }) => {
setIsEmpty(!e.getText().trim());
},
});
useEffect(() => {
if (editor && autofocus) {
editor.commands.focus();
}
}, [editor]);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
return (
<>
<div className={wrapperClass} data-chat-input>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES}
multiple
style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
<div className={classes.attachmentChips}>
{contextPages?.map((page) => (
<div key={page.id} className={classes.attachmentChip}>
<IconFileText size={14} />
<span className={classes.attachmentChipName}>
{page.title || "Untitled"}
</span>
{onRemoveContextPage && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => onRemoveContextPage(page.id)}
aria-label={`Remove ${page.title}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
>
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
<IconPhoto size={14} />
) : (
<IconFile size={14} />
)}
<span className={classes.attachmentChipName}>
{attachment.fileName}
</span>
{!attachment.uploading && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => removeAttachment(attachment.id)}
aria-label={`Remove ${attachment.fileName}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
</div>
)}
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
<Popover.Target>
<button
type="button"
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
setPlusMenuOpen(false);
}}
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
title={
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
? t("Max {{max}} files per message", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
})
: undefined
}
>
<IconPaperclip size={16} className={classes.plusMenuIcon} />
{t("Add files")}
</button>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
editor?.commands.focus();
setPlusMenuOpen(false);
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
</button>
</Popover.Dropdown>
</Popover>
<div style={{ flex: 1 }} />
{isStreaming ? (
<button
type="button"
className={classes.stopButton}
onClick={onStop}
aria-label="Stop generation"
>
<IconPlayerStopFilled size={14} />
</button>
) : (
<button
type="button"
className={classes.sendButton}
onClick={handleSubmit}
disabled={!hasContent}
aria-label="Send message"
>
<IconArrowUp size={16} stroke={2.5} />
</button>
)}
</div>
</div>
{showDisclaimer && (
<div className={classes.disclaimer}>
{t("AI-generated content may not be accurate.")}
</div>
)}
</>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
function ChatMessageErrorFallback() {
const { t } = useTranslation();
return (
<div className={classes.messageErrorFallback}>
<IconAlertTriangle size={14} />
<span>{t("Failed to render this message.")}</span>
</div>
);
}
type Props = {
messages: AiChatMessage[];
isStreaming: boolean;
streamingContent: string;
streamingToolCalls: AiChatToolCall[];
};
const BOTTOM_THRESHOLD_PX = 32;
const SCROLL_UP_THRESHOLD_PX = 5;
const SMOOTH_SCROLL_SETTLE_MS = 600;
export default function ChatMessageList({
messages,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const isAutoScrollingRef = useRef(false);
const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
isAutoScrollingRef.current = true;
const target = container.scrollHeight - container.clientHeight;
container.scrollTo({ top: target, behavior });
prevScrollTopRef.current = target;
isAtBottomRef.current = true;
setShowScrollButton(false);
if (behavior === "smooth") {
setTimeout(() => {
isAutoScrollingRef.current = false;
if (containerRef.current) {
prevScrollTopRef.current = containerRef.current.scrollTop;
}
}, SMOOTH_SCROLL_SETTLE_MS);
} else {
isAutoScrollingRef.current = false;
}
}, []);
const handleScroll = useCallback(() => {
if (isAutoScrollingRef.current) return;
const container = containerRef.current;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrolledUp =
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
prevScrollTopRef.current = currentScrollTop;
const distanceFromBottom =
container.scrollHeight - currentScrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
if (scrolledUp) {
isAtBottomRef.current = atBottom;
} else if (atBottom) {
isAtBottomRef.current = true;
}
setShowScrollButton(!atBottom);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
// Instant scroll during streaming to keep up with rapid updates
useEffect(() => {
if (isAtBottomRef.current) {
scrollToBottom("instant");
}
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
// Smooth scroll for new messages. Always force-scroll when the latest
// message is from the user (they just sent it), even if they were reading
// scrollback.
useEffect(() => {
const lastMessage = messages[messages.length - 1];
const lastIsUser = lastMessage?.role === "user";
if (lastIsUser || isAtBottomRef.current) {
scrollToBottom("smooth");
return;
}
// No auto-scroll: recompute from actual layout so that chat switches to
// content that doesn't overflow correctly hide the button even when no
// scroll event fires.
const container = containerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
}, [messages, scrollToBottom]);
return (
<div className={classes.messageListWrapper}>
<div ref={containerRef} className={classes.messageList}>
{messages.map((msg) => (
<ErrorBoundary
key={msg.id}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage message={msg} />
</ErrorBoundary>
))}
{isStreaming && (
<ErrorBoundary
resetKeys={[streamingContent, streamingToolCalls.length]}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage
message={{
id: "streaming",
chatId: "",
role: "assistant",
content: null,
toolCalls: null,
metadata: null,
createdAt: new Date().toISOString(),
}}
isStreaming
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</ErrorBoundary>
)}
<div ref={bottomRef} />
</div>
{showScrollButton && (
<button
type="button"
aria-label="Scroll to bottom"
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
<IconArrowDown size={16} stroke={2} />
</button>
)}
</div>
);
}
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconCheck,
IconCopy,
IconFile,
IconLoader2,
IconPhoto,
} from "@tabler/icons-react";
import { markdownToHtml } from "@docmost/editor-ext";
import { CopyButton } from "@/components/common/copy-button";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx";
const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") || "";
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
}
});
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
type Props = {
message: AiChatMessage;
isStreaming?: boolean;
streamingContent?: string;
streamingToolCalls?: AiChatToolCall[];
};
export default function ChatMessage({
message,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const navigate = useNavigate();
const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const anchor = target.closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) {
e.preventDefault();
navigate(href);
}
},
[navigate],
);
if (message.role === "tool") return null;
const isUser = message.role === "user";
const content = isStreaming ? streamingContent : message.content;
const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls;
if (isUser) {
const displayContent = (content || "").replace(
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
"",
);
const attachments =
(message.metadata?.attachments as {
id: string;
fileName: string;
fileExt: string;
}[]) || [];
return (
<div className={classes.userMessage}>
<div className={classes.userBubble}>
{attachments.length > 0 && (
<div className={classes.messageAttachments}>
{attachments.map((a) => (
<span key={a.id} className={classes.messageAttachmentChip}>
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
<IconPhoto size={13} />
) : (
<IconFile size={13} />
)}
{a.fileName}
</span>
))}
</div>
)}
{displayContent}
</div>
</div>
);
}
return (
<div className={classes.assistantMessage}>
<div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
)}
{content && (
<div
onClick={handleContentClick}
dangerouslySetInnerHTML={{
__html: chatSanitizer.sanitize(
markdownToHtml(content) as string,
{ ADD_ATTR: ["target", "rel"] },
),
}}
/>
)}
{isStreaming && (
<>
{!content && (
<span className={classes.processingIndicator}>
<IconLoader2 size={16} className={classes.processingSpinner} />
Thinking
</span>
)}
<span className={classes.streamingCursor} />
</>
)}
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton text={message?.content} />
</div>
)}
</div>
);
}
@@ -0,0 +1,56 @@
import { useState } from "react";
import {
IconChevronRight,
IconChevronDown,
IconLoader2,
} from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
import classes from "../styles/chat-message.module.css";
type Props = {
toolCalls: AiChatToolCall[];
isStreaming?: boolean;
};
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
const [expanded, setExpanded] = useState(false);
if (!toolCalls || toolCalls.length === 0) return null;
const activeCall =
isStreaming && toolCalls.length > 0
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
: undefined;
const activeLabel = activeCall
? TOOL_LABELS[activeCall.name] || activeCall.name
: null;
return (
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
onClick={() => setExpanded((prev) => !prev)}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
) : expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span className={classes.toolGroupLabel}>
{activeLabel ? `${activeLabel}` : `Steps ${toolCalls.length}`}
</span>
</div>
{expanded && (
<div className={classes.toolGroupSteps}>
{toolCalls.map((tc) => (
<ChatToolResult key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,49 @@
import { useState } from "react";
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import classes from "../styles/chat-message.module.css";
export const TOOL_LABELS: Record<string, string> = {
list_spaces: "Listed spaces",
search_pages: "Searched pages",
get_page: "Read page",
create_page: "Created page",
update_page: "Updated page",
};
type Props = {
toolCall: AiChatToolCall;
};
export default function ChatToolResult({ toolCall }: Props) {
const [expanded, setExpanded] = useState(false);
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
return (
<div className={classes.toolStep}>
<div
className={classes.toolStepRow}
onClick={() => setExpanded((prev) => !prev)}
>
<span className={classes.toolStepBullet}>·</span>
{expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span>{label}</span>
</div>
{expanded && (
<div className={classes.toolStepDetails}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{JSON.stringify(
{ args: toolCall.args, result: toolCall.result },
null,
2,
)}
</pre>
</div>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiChat() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("AI Chat")}</Text>
<Badge color="gray" variant="light" size="sm" radius="sm">
{t("Beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
)}
</Text>
</div>
<AiChatToggle />
</Group>
);
}
function AiChatToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiChat: value } as any);
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat")}
/>
</Tooltip>
);
}
@@ -0,0 +1,227 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { sendChatMessage } from "../services/ai-chat-service";
import type {
AiChatMessage,
AiChatStreamEvent,
AiChatToolCall,
ChatAttachment,
PageMention,
} from "../types/ai-chat.types";
type ChatStreamOptions = {
onChatCreated?: (chatId: string) => void;
};
export function useChatStream(
chatId: string | undefined,
options?: ChatStreamOptions,
) {
const [messages, setMessages] = useState<AiChatMessage[]>([]);
const [streamingContent, setStreamingContent] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
[],
);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [isRetryable, setIsRetryable] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
const currentChatIdRef = useRef(chatId);
currentChatIdRef.current = chatId;
// Tracks which chatId the local `messages` state currently represents.
// Set when we seed from a server fetch AND when we optimistically own a
// freshly-created chat after `chat_created`. This is the single authority
// marker that keeps server-state effects from clobbering in-flight streams.
const hydratedChatIdRef = useRef<string | undefined>(undefined);
// Reset local state when the consumer switches to a different chat.
// Skip the reset if the new chatId is one the hook itself already claimed
// during a new-chat flow — in that case our optimistic state is the truth.
useEffect(() => {
if (chatId && chatId === hydratedChatIdRef.current) return;
hydratedChatIdRef.current = undefined;
setMessages([]);
setError(null);
setErrorCode(null);
setIsRetryable(false);
}, [chatId]);
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
const forId = currentChatIdRef.current;
if (!forId) return;
if (hydratedChatIdRef.current === forId) return;
hydratedChatIdRef.current = forId;
setMessages(msgs);
}, []);
const sendMessage = useCallback(
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
setError(null);
setErrorCode(null);
setIsRetryable(false);
setIsStreaming(true);
setStreamingContent("");
setStreamingToolCalls([]);
const metadata: Record<string, unknown> = {};
if (mentions.length) {
metadata.mentionedPageIds = mentions.map((m) => m.id);
}
if (attachments.length) {
metadata.attachments = attachments.map((a) => ({
id: a.id,
fileName: a.fileName,
fileExt: a.fileExt,
}));
}
const userMessage: AiChatMessage = {
id: `temp-${Date.now()}`,
chatId: currentChatIdRef.current || "",
role: "user",
content,
toolCalls: null,
metadata: Object.keys(metadata).length ? metadata : null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
const attachmentIds = attachments.map((a) => a.id);
const abortController = sendChatMessage(
{
chatId: currentChatIdRef.current,
content,
mentionedPageIds: mentions.map((m) => m.id),
...(contextPageId && { contextPageId }),
...(attachmentIds.length && { attachmentIds }),
},
(event: AiChatStreamEvent) => {
switch (event.type) {
case "chat_created":
currentChatIdRef.current = event.chatId;
// Claim authority over this new chatId so when the consumer's
// prop catches up via navigation/onChatCreated, the reset effect
// sees a match and preserves our optimistic messages.
hydratedChatIdRef.current = event.chatId;
if (options?.onChatCreated) {
options.onChatCreated(event.chatId);
} else {
navigate(`/ai/chat/${event.chatId}`, { replace: true });
}
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
break;
case "content":
setStreamingContent((prev) => prev + event.text);
break;
case "tool_call":
setStreamingToolCalls((prev) => [
...prev,
{
id: event.id,
name: event.name,
args: event.args,
},
]);
break;
case "tool_result":
setStreamingToolCalls((prev) =>
prev.map((tc) =>
tc.id === event.id ? { ...tc, result: event.result } : tc,
),
);
break;
case "done": {
setStreamingContent((currentContent) => {
setStreamingToolCalls((currentToolCalls) => {
const assistantMessage: AiChatMessage = {
id: event.messageId,
chatId: currentChatIdRef.current || "",
role: "assistant",
content: currentContent || null,
toolCalls: currentToolCalls.length
? currentToolCalls
: null,
metadata: event.usage ? { tokenUsage: event.usage } : null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
return [];
});
return "";
});
setIsStreaming(false);
queryClient.invalidateQueries({
queryKey: ["ai-chat", currentChatIdRef.current],
});
break;
}
case "error":
setError(event.message);
setErrorCode(event.code || null);
setIsRetryable(event.retryable || false);
setIsStreaming(false);
break;
}
},
(errorMsg) => {
setError(errorMsg);
setIsStreaming(false);
},
() => {
setIsStreaming(false);
},
);
abortRef.current = abortController;
},
[isStreaming, navigate, queryClient],
);
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStreamingContent((currentContent) => {
setStreamingToolCalls((currentToolCalls) => {
if (currentContent || currentToolCalls.length > 0) {
const partialMessage: AiChatMessage = {
id: `stopped-${Date.now()}`,
chatId: currentChatIdRef.current || "",
role: "assistant",
content: currentContent || null,
toolCalls: currentToolCalls.length ? currentToolCalls : null,
metadata: null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, partialMessage]);
}
return [];
});
return "";
});
setIsStreaming(false);
}, []);
return {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
errorCode,
isRetryable,
sendMessage,
stopGeneration,
hydrateFromServer,
};
}
@@ -0,0 +1,39 @@
import { useParams } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import AiChatLayout from "../components/ai-chat-layout";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import classes from "../styles/ai-chat.module.css";
export default function AiChat() {
const { t } = useTranslation();
const { chatId } = useParams<{ chatId: string }>();
return (
<div className={classes.layout}>
<ErrorBoundary
resetKeys={[chatId]}
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load chat. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={resetErrorBoundary}
>
{t("Try again")}
</Button>
}
/>
)}
>
<AiChatLayout />
</ErrorBoundary>
</div>
);
}
@@ -0,0 +1,61 @@
import {
useQuery,
useMutation,
useQueryClient,
useInfiniteQuery,
} from "@tanstack/react-query";
import {
listChats,
getChatInfo,
deleteChat,
updateChatTitle,
searchChats,
} from "../services/ai-chat-service";
export function useChatsQuery() {
return useInfiniteQuery({
queryKey: ["ai-chats"],
queryFn: ({ pageParam }) =>
listChats({ cursor: pageParam, limit: 30 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
export function useChatInfoQuery(chatId: string | undefined) {
return useQuery({
queryKey: ["ai-chat", chatId],
queryFn: () => getChatInfo(chatId!),
enabled: !!chatId,
});
}
export function useDeleteChatMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (chatId: string) => deleteChat(chatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
},
});
}
export function useUpdateChatTitleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
updateChatTitle(chatId, title),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
},
});
}
export function useSearchChatsQuery(query: string) {
return useQuery({
queryKey: ["ai-chats-search", query],
queryFn: () => searchChats(query),
enabled: query.length > 0,
});
}
@@ -0,0 +1,144 @@
import api from "@/lib/api-client.ts";
import type {
AiChat,
AiChatMessage,
AiChatStreamEvent,
ChatAttachment,
} from "../types/ai-chat.types";
import { IPagination } from "@/lib/types.ts";
export async function createChat(): Promise<AiChat> {
const req = await api.post<AiChat>("/ai/chats/create");
return req.data;
}
export async function listChats(params?: {
limit?: number;
cursor?: string;
}): Promise<IPagination<AiChat>> {
const req = await api.post("/ai/chats", params);
return req.data;
}
export async function getChatInfo(
chatId: string,
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
const req = await api.post("/ai/chats/info", { chatId });
return req.data;
}
export async function deleteChat(chatId: string): Promise<void> {
await api.post("/ai/chats/delete", { chatId });
}
export async function updateChatTitle(
chatId: string,
title: string,
): Promise<void> {
await api.post("/ai/chats/update", { chatId, title });
}
export async function searchChats(query: string): Promise<AiChat[]> {
const req = await api.post("/ai/chats/search", { query });
return req.data;
}
export async function uploadChatFile(
file: File,
chatId?: string,
): Promise<ChatAttachment> {
const formData = new FormData();
formData.append("file", file);
if (chatId) {
formData.append("chatId", chatId);
}
return await api.post("/ai/chats/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
}
export function sendChatMessage(
params: {
chatId?: string;
content: string;
mentionedPageIds?: string[];
contextPageId?: string;
attachmentIds?: string[];
},
onEvent: (event: AiChatStreamEvent) => void,
onError?: (error: string) => void,
onComplete?: () => void,
): AbortController {
const abortController = new AbortController();
(async () => {
try {
const response = await fetch("/api/ai/chats/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
signal: abortController.signal,
credentials: "include",
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = `HTTP error ${response.status}`;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.message || errorMessage;
} catch {
// use default
}
onError?.(errorMessage);
return;
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
onError?.("Response body is not readable");
return;
}
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data) as AiChatStreamEvent;
onEvent(parsed);
} catch {
// Skip invalid JSON
}
}
}
}
} finally {
reader.releaseLock();
}
onComplete?.();
} catch (error: any) {
if (error.name !== "AbortError") {
onError?.(error.message);
}
}
})();
return abortController;
}
@@ -0,0 +1,169 @@
.layout {
display: flex;
height: 100%;
width: 100%;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
height: calc(100vh - 45px - 2 * var(--mantine-spacing-md));
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.messageListWrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
width: 100%;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
}
.messageErrorFallback {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
}
.scrollToBottomButton {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
cursor: pointer;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
z-index: 2;
}
.scrollToBottomButton:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.scrollToBottomButton:active {
transform: translateX(-50%) scale(0.95);
}
.inputArea {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
}
/* Empty state - Notion AI style centered layout */
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg);
}
.emptyStateIcon {
width: 48px;
height: 48px;
margin-bottom: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.emptyStateBrand {
font-size: var(--mantine-font-size-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-bottom: var(--mantine-spacing-xs);
}
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-bottom: var(--mantine-spacing-xl);
text-align: center;
}
.emptyStateInput {
width: 100%;
max-width: 600px;
margin-bottom: var(--mantine-spacing-xl);
padding: 6px 0;
}
.suggestionsSection {
width: 100%;
max-width: 600px;
}
.suggestionsLabel {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--mantine-spacing-sm);
}
.suggestionsGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--mantine-spacing-sm);
}
.suggestionCard {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
cursor: pointer;
background: transparent;
transition: background-color 150ms, border-color 150ms;
text-align: left;
width: 100%;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.suggestionIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-top: 1px;
}
.suggestionText {
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
line-height: 1.4;
}
@@ -0,0 +1,139 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 0 var(--mantine-spacing-sm) 0;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.toolbarSpacer {
flex: 1;
}
.titleButton {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
max-width: 60%;
min-width: 0;
}
.titleButton:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.titleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-sm) 0;
scroll-behavior: smooth;
}
.inputArea {
padding-top: var(--mantine-spacing-sm);
}
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
}
.emptyStateIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.emptyStateTitle {
font-size: var(--mantine-font-size-lg);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
text-align: center;
}
.quickActions {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.quickAction {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
cursor: pointer;
background: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
text-align: left;
width: 100%;
transition: background-color 150ms, border-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.quickActionIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.historyList {
max-height: 300px;
overflow-y: auto;
}
.historyItem {
display: flex;
align-items: center;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
cursor: pointer;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&[data-active] {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.historyItemTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,242 @@
.inputWrapper {
position: relative;
overflow: hidden;
border: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 16px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: light-dark(
0 2px 40px 4px rgba(0, 0, 0, 0.07),
0 2px 40px 4px rgba(0, 0, 0, 0.5)
);
transition:
border-color 150ms,
box-shadow 150ms;
&:focus-within {
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
box-shadow: light-dark(
0 4px 48px 6px rgba(0, 0, 0, 0.09),
0 4px 48px 6px rgba(0, 0, 0, 0.6)
);
}
}
.inputWrapperFlat {
position: relative;
overflow: hidden;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 12px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: none;
transition: border-color 150ms;
&:focus-within {
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.disclaimer {
margin-top: 6px;
text-align: center;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.attachmentChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 14px 0;
}
.attachmentChip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 8px;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-xs);
max-width: 200px;
}
.attachmentChipUploading {
opacity: 0.55;
}
.attachmentChipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachmentChipRemove {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
padding: 0;
margin-left: 2px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
border-radius: 50%;
@mixin hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
}
.editorContent {
overflow: hidden;
:global(.ProseMirror) {
outline: none;
border: none;
background-color: transparent;
padding: 14px 18px 8px;
font-size: 15px;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
min-height: 24px;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
:global(.ProseMirror p) {
margin-block-start: 0;
margin-block-end: 0;
}
:global(.ProseMirror p.is-editor-empty:first-child::before) {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 4px 12px 10px;
gap: var(--mantine-spacing-xs);
}
.sendButton {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: background-color 150ms, opacity 150ms;
background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9));
&:disabled {
opacity: 0.25;
cursor: default;
}
@mixin hover {
&:not(:disabled) {
opacity: 0.85;
}
}
}
.attachButton {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
padding: 2px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
.plusButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}
.plusMenuItem {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: none;
background: none;
cursor: pointer;
width: 100%;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
border-radius: var(--mantine-radius-sm);
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
background: none;
}
}
.plusMenuIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.stopButton {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
cursor: pointer;
transition: background-color 150ms;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
@mixin hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@@ -0,0 +1,286 @@
.message {
margin-bottom: var(--mantine-spacing-lg);
}
.userMessage {
composes: message;
display: flex;
justify-content: flex-end;
}
.userBubble {
max-width: 75%;
padding: 10px 16px;
border-radius: 18px;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
font-size: 15px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
}
[data-aside-chat] .userBubble {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.userBubble p {
margin: 0;
}
.messageAttachments {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.messageAttachmentChip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
background: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistantMessage {
composes: message;
}
.messageContent {
font-size: 15px;
line-height: 1.7;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
word-wrap: break-word;
overflow-wrap: break-word;
}
.messageContent p {
margin: 0 0 0.75em 0;
}
.messageContent p:last-child {
margin-bottom: 0;
}
.messageContent ul,
.messageContent ol {
margin: 0.5em 0 0.75em 0;
padding-left: 1.5em;
}
.messageContent li {
margin-bottom: 0.3em;
}
.messageContent h1,
.messageContent h2,
.messageContent h3 {
margin: 1em 0 0.5em 0;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
.messageContent h1 {
font-size: 1.4em;
}
.messageContent h2 {
font-size: 1.2em;
}
.messageContent h3 {
font-size: 1.05em;
}
.messageContent pre {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
overflow-x: auto;
font-size: var(--mantine-font-size-sm);
margin: 0.75em 0;
}
.messageContent code {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
padding: 2px 6px;
border-radius: 4px;
font-size: 0.88em;
}
.messageContent pre code {
background: none;
padding: 0;
}
.messageContent blockquote {
border-left: 3px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: var(--mantine-spacing-md);
margin: 0.75em 0;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
}
.messageContent a {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
text-decoration: none;
@mixin hover {
text-decoration: underline;
}
}
.messageContent a[href^="/s/"],
.messageContent a[href^="/p/"] {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
font-weight: 500;
text-decoration: none;
cursor: pointer;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
@mixin hover {
text-decoration: none;
@mixin light {
border-bottom-color: var(--mantine-color-dark-2);
}
@mixin dark {
border-bottom-color: var(--mantine-color-dark-0);
}
}
}
.messageContent hr {
border: none;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
margin: 1em 0;
}
.toolGroup {
margin: 6px 0;
font-size: var(--mantine-font-size-xs);
}
.toolGroupHeader {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.4;
transition: color 120ms ease;
}
.toolGroupHeader:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolGroupLabel {
font-weight: 500;
}
.toolGroupSteps {
margin-top: 4px;
padding-left: 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.toolStep {
font-size: var(--mantine-font-size-xs);
}
.toolStepRow {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.5;
transition: color 120ms ease;
}
.toolStepRow:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolStepBullet {
display: inline-block;
width: 8px;
text-align: center;
opacity: 0.6;
}
.toolStepDetails {
margin-top: 4px;
margin-left: 18px;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
}
.messageActions {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.processingIndicator {
display: inline-flex;
align-items: center;
gap: 6px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-sm);
}
.processingSpinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.streamingCursor {
display: inline-block;
width: 2px;
height: 1em;
background: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
@keyframes blink {
50% {
opacity: 0;
}
}
@@ -0,0 +1,138 @@
.sidebar {
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--mantine-spacing-xs);
}
.title {
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
.searchInput {
margin-bottom: var(--mantine-spacing-xs);
}
.chatList {
flex: 1;
overflow-y: auto;
}
.chatGroup + .chatGroup {
margin-top: var(--mantine-spacing-sm);
}
.chatGroupLabel {
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none;
}
.chatListEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
text-align: center;
gap: 4px;
user-select: none;
}
.chatListEmptyIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
margin-bottom: var(--mantine-spacing-xs);
}
.chatListEmptyTitle {
font-size: var(--mantine-font-size-sm);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.chatListEmptyHint {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
line-height: 1.4;
}
.chatItem {
display: flex;
align-items: center;
padding: 8px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
cursor: pointer;
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
user-select: none;
gap: var(--mantine-spacing-xs);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
&[data-active] {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
}
.chatItemTitle {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatItemDate {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
transition: opacity 150ms;
}
.chatItemRenameInput {
font-size: var(--mantine-font-size-sm);
padding: 0;
height: auto;
min-height: 0;
background: transparent;
color: inherit;
}
.chatItem:hover .chatItemDate {
opacity: 0;
}
.chatItemActions {
position: absolute;
right: var(--mantine-spacing-xs);
opacity: 0;
transition: opacity 150ms;
}
.chatItem {
position: relative;
}
.chatItem:hover .chatItemActions {
opacity: 1;
}
@@ -0,0 +1,49 @@
export type AiChat = {
id: string;
workspaceId: string;
creatorId: string;
title: string | null;
createdAt: string;
updatedAt: string;
};
export type AiChatToolCall = {
id: string;
name: string;
args: Record<string, unknown>;
result?: unknown;
};
export type AiChatMessage = {
id: string;
chatId: string;
role: 'user' | 'assistant' | 'tool';
content: string | null;
toolCalls: AiChatToolCall[] | null;
metadata: Record<string, unknown> | null;
createdAt: string;
};
export type AiChatStreamEvent =
| { type: 'chat_created'; chatId: string }
| { type: 'content'; text: string }
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
| { type: 'tool_result'; id: string; result: unknown }
| { type: 'done'; messageId: string; usage?: Record<string, number> }
| { type: 'error'; message: string; code?: string; retryable?: boolean };
export type PageMention = {
id: string;
title: string;
slugId: string;
spaceSlug?: string;
icon?: string;
};
export type ChatAttachment = {
id: string;
fileName: string;
fileExt: string;
fileSize: number;
mimeType: string;
};
@@ -0,0 +1,45 @@
import type { AiChat } from "../types/ai-chat.types";
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
export function groupChatsByAge(
chats: AiChat[],
t: (key: string) => string,
): ChatGroup[] {
if (chats.length === 0) return [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
const buckets: Record<string, ChatGroup> = {
today: { key: "today", label: t("Today"), chats: [] },
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
older: { key: "older", label: t("Older"), chats: [] },
};
for (const chat of chats) {
const ts = new Date(chat.updatedAt).getTime();
if (ts >= startOfToday) buckets.today.chats.push(chat);
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
else buckets.older.chats.push(chat);
}
return [
buckets.today,
buckets.yesterday,
buckets.last7,
buckets.last30,
buckets.older,
].filter((b) => b.chats.length > 0);
}
@@ -6,6 +6,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
@@ -71,6 +72,7 @@ export default function AiSettings() {
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
<EnableAiChat />
</Stack>
</Tabs.Panel>
+2
View File
@@ -16,4 +16,6 @@ export const Feature = {
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
@@ -71,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
) : null
}
variant="default"
onClick={open}
onClick={() => {
setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish");
open();
}}
>
{t("Share")}
</Button>
@@ -0,0 +1,70 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function AllowMemberTemplates() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow members to create templates")}</Text>
<Text size="sm" c="dimmed">
{t(
"Allow non-admin members to create and manage templates in their spaces.",
)}
</Text>
</div>
<AllowMemberTemplatesToggle />
</Group>
);
}
function AllowMemberTemplatesToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.templates?.allowMemberTemplates === true,
);
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
allowMemberTemplates: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasSecuritySettings}
aria-label={t("Toggle allow members to create templates")}
/>
</Tooltip>
);
}
@@ -0,0 +1,61 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceViewerCommentsToggleProps = {
space: ISpace;
};
export default function SpaceViewerCommentsToggle({
space,
}: SpaceViewerCommentsToggleProps) {
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
allowViewerComments: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow viewers to comment")}</Text>
<Text size="sm" c="dimmed">
{t("Allow viewers to add comments on pages in this space.")}
</Text>
</div>
<Tooltip
label={upgradeLabel}
disabled={!isDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
</Group>
);
}
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
@@ -0,0 +1,118 @@
import { useState } from "react";
import {
Modal,
TextInput,
Select,
Button,
Stack,
Group,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useCreateTemplateMutation } from "../queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role";
type CreateTemplateModalProps = {
opened: boolean;
onClose: () => void;
};
export default function CreateTemplateModal({
opened,
onClose,
}: CreateTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const createMutation = useCreateTemplateMutation();
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
const [title, setTitle] = useState("");
const [spaceId, setSpaceId] = useState<string | null>(null);
const scopeOptions = [
...(isWorkspaceAdmin
? [
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
]
: []),
...(spaces?.items?.length
? [
{
group: t("Spaces"),
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
},
]
: []),
];
const handleCreate = async () => {
if (!title.trim()) return;
try {
const result = await createMutation.mutateAsync({
title: title.trim(),
spaceId: spaceId || undefined,
});
handleClose();
navigate(`/templates/${result.id}`);
} catch {
// error notification handled by mutation's onError
}
};
const handleClose = () => {
setTitle("");
setSpaceId(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("New template")}
centered
>
<Stack gap="md">
<TextInput
label={t("Title")}
placeholder={t("Untitled")}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
data-autofocus
onKeyDown={(e) => {
if (e.key === "Enter" && title.trim() && !createMutation.isPending) {
handleCreate();
}
}}
/>
<Select
label={t("Scope")}
description={t("Choose which space this template belongs to")}
data={scopeOptions}
value={spaceId || ""}
onChange={(val) => setSpaceId(val || null)}
searchable
placeholder={t("Select scope")}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button
onClick={handleCreate}
loading={createMutation.isPending}
disabled={!title.trim()}
>
{t("Create")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,49 @@
import "@/features/editor/styles/index.css";
import { useMemo } from "react";
import { Title } from "@mantine/core";
import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
import { ITemplate } from "@/ee/template/types/template.types";
import TemplateMeta from "@/ee/template/components/template-meta";
type ReadonlyTemplateEditorProps = {
template: ITemplate;
};
export default function ReadonlyTemplateEditor({
template,
}: ReadonlyTemplateEditorProps) {
const extensions = useMemo(() => {
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
}, []);
return (
<>
<div style={{ padding: "0 3rem" }}>
<Title order={1} size="2.5rem" lh={1.2}>
{template.title || "Untitled"}
</Title>
<TemplateMeta template={template} />
</div>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={template.content}
/>
</>
);
}
@@ -0,0 +1,67 @@
.card {
display: flex;
flex-direction: column;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
transition: transform 150ms ease;
box-shadow: light-dark(rgba(0, 0, 0, 0.07) 0px 2px 45px 4px, none);
@mixin hover {
transform: scale(1.02);
}
}
.cardBody {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.icon {
font-size: 22px;
line-height: 1;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
flex-shrink: 0;
}
.iconFallback {
composes: icon;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.title {
font-weight: 600;
font-size: var(--mantine-font-size-md);
line-height: 1.35;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-0));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.menuTarget {
opacity: 0;
transition: opacity 100ms ease;
.card:hover & {
opacity: 1;
}
}
@@ -0,0 +1,100 @@
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import {
IconDots,
IconEdit,
IconTrash,
IconFileText,
} from "@tabler/icons-react";
import { ITemplate } from "@/ee/template/types/template.types";
import { useTranslation } from "react-i18next";
import classes from "./template-card.module.css";
type TemplateCardProps = {
template: ITemplate;
spaceName?: string;
onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void;
canManage?: boolean;
};
export default function TemplateCard({
template,
spaceName,
onUse,
onEdit,
onDelete,
canManage,
}: TemplateCardProps) {
const { t } = useTranslation();
return (
<Card
radius="md"
padding="lg"
className={classes.card}
style={{ cursor: "pointer" }}
onClick={() => onUse(template)}
>
<div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
{template.icon ? (
<div className={classes.icon}>{template.icon}</div>
) : (
<div className={classes.iconFallback}>
<IconFileText size={20} stroke={1.5} />
</div>
)}
<Group gap={6} wrap="nowrap">
{canManage && (
<Menu width={150} shadow="md" withArrow>
<Menu.Target>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
className={classes.menuTarget}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={(e) => {
e.stopPropagation();
onEdit?.(template);
}}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={(e) => {
e.stopPropagation();
onDelete?.(template);
}}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
<div className={classes.title}>{template.title}</div>
<div className={classes.footer}>
<Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,37 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { ITemplate } from "@/ee/template/types/template.types";
type TemplateMetaProps = {
template: ITemplate;
};
export default function TemplateMeta({ template }: TemplateMetaProps) {
const { t } = useTranslation();
const updatedAtAgo = useTimeAgo(template.updatedAt);
return (
<Group gap={8} mt="xs" wrap="nowrap" style={{ cursor: "default" }}>
{template.creator?.name && (
<>
<CustomAvatar
size={24}
radius="xl"
name={template.creator.name}
avatarUrl={template.creator.avatarUrl}
/>
<Text size="sm" c="dimmed" fw={500}>
{t("By {{name}}", { name: template.creator.name })}
</Text>
</>
)}
{updatedAtAgo && (
<Text size="sm" c="dimmed">
{t("Updated {{time}}", { time: updatedAtAgo })}
</Text>
)}
</Group>
);
}
@@ -0,0 +1,65 @@
import { Modal, Text, ScrollArea, Button, Group, Center, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useGetTemplateByIdQuery } from "@/ee/template/queries/template-query";
import ReadonlyTemplateEditor from "@/ee/template/components/readonly-template-editor";
type TemplatePreviewModalProps = {
templateId: string;
opened: boolean;
onClose: () => void;
onUse: () => void;
onEdit?: () => void;
};
export default function TemplatePreviewModal({
templateId,
opened,
onClose,
onUse,
onEdit,
}: TemplatePreviewModalProps) {
const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
const title = template?.title || t("Untitled");
return (
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Group gap="xs">
{template?.icon && <Text size="lg">{template.icon}</Text>}
<Text size="md" fw={500}>
{title}
</Text>
</Group>
</Modal.Title>
<Group gap="sm">
{onEdit && (
<Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")}
</Button>
)}
<Button size="xs" onClick={onUse}>
{t("Use template")}
</Button>
<Modal.CloseButton />
</Group>
</Modal.Header>
<Modal.Body p={0}>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<ScrollArea h="80vh" w="100%" scrollbarSize={5}>
{template && <ReadonlyTemplateEditor template={template} />}
</ScrollArea>
)}
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ITemplate } from "@/ee/template/types/template.types";
import { useUseTemplateMutation } from "@/ee/template/queries/template-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { DestinationPickerModal } from "@/components/ui/destination-picker/destination-picker-modal";
import { DestinationSelection } from "@/components/ui/destination-picker/destination-picker.types";
type UseTemplateModalProps = {
template: ITemplate;
opened: boolean;
onClose: () => void;
};
export default function UseTemplateModal({
template,
opened,
onClose,
}: UseTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const useTemplateMutation = useUseTemplateMutation();
const handleSelect = async (selection: DestinationSelection) => {
const spaceId = selection.spaceId;
const parentPageId =
selection.type === "page" ? selection.pageId : undefined;
try {
const page = await useTemplateMutation.mutateAsync({
templateId: template.id,
spaceId,
parentPageId,
});
onClose();
if (page?.slugId) {
const space = selection.space;
if (space?.slug) {
navigate(buildPageUrl(space.slug, page.slugId, page.title));
}
}
} catch {
// error notification handled by mutation's onError
}
};
return (
<DestinationPickerModal
opened={opened}
onClose={onClose}
title={t("Choose destination")}
actionLabel={t("Create page")}
onSelect={handleSelect}
loading={useTemplateMutation.isPending}
/>
);
}
@@ -0,0 +1,68 @@
.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);
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
}
.editor {
height: 100%;
padding: 8px 0;
margin: 48px auto;
}
.titleArea {
padding: 0 3rem;
margin-bottom: 1.5em;
}
.emojiButton {
display: flex;
align-items: center;
margin-bottom: 0.25em;
}
.titleInput {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
border: none;
outline: none;
width: 100%;
padding: 0;
background: transparent;
color: inherit;
&::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
}
.emojiIcon {
font-size: 3rem;
line-height: 1;
}
.backLink {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
text-decoration: none;
font-size: var(--mantine-font-size-sm);
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
@mixin hover {
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-0));
}
}
@@ -0,0 +1,373 @@
import "@/features/editor/styles/index.css";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
Container,
Group,
Select,
Popover,
Stack,
ActionIcon,
Text,
} from "@mantine/core";
import {
IconArrowLeft,
IconSettings,
IconMoodSmile,
IconCheck,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker";
import TemplateMeta from "@/ee/template/components/template-meta";
import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
useUpdateTemplateMutation,
useGetTemplateByIdQuery,
} from "../queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role";
import classes from "./template-editor.module.css";
export default function TemplateEditor() {
const { t } = useTranslation();
const { templateId } = useParams<{ templateId: string }>();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
const updateMutation = useUpdateTemplateMutation();
const updateMutationRef = useRef(updateMutation.mutateAsync);
updateMutationRef.current = updateMutation.mutateAsync;
const [title, setTitle] = useState("");
const [icon, setIcon] = useState<string | null>(null);
const [spaceId, setSpaceId] = useState<string | null>(null);
const [draftSpaceId, setDraftSpaceId] = useState<string | null>(null);
const [settingsOpened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
useWindowEvent("keydown", (event) => {
if (settingsOpened && event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
closeSettings();
}
});
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const titleRef = useRef(title);
const iconRef = useRef(icon);
const spaceIdRef = useRef(spaceId);
const loadedRef = useRef(false);
const isDirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedFadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor({
extensions: templateExtensions,
content: "",
onUpdate() {
if (loadedRef.current) {
markDirty();
}
},
});
// Load template data into editor
useEffect(() => {
if (existingTemplate && editor) {
loadedRef.current = false;
setTitle(existingTemplate.title || "");
setIcon(existingTemplate.icon || null);
setSpaceId(existingTemplate.spaceId || null);
titleRef.current = existingTemplate.title || "";
iconRef.current = existingTemplate.icon || null;
spaceIdRef.current = existingTemplate.spaceId || null;
if (existingTemplate.content) {
editor.commands.setContent(existingTemplate.content);
}
requestAnimationFrame(() => {
loadedRef.current = true;
});
}
}, [existingTemplate, editor]);
const spaceOptions = [
...(isWorkspaceAdmin
? [
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
]
: []),
...(spaces?.items?.length
? [
{
group: t("Spaces"),
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
},
]
: []),
];
// Save function
const save = useCallback(async () => {
if (!editor || !templateId || !titleRef.current.trim()) return;
if (!isDirtyRef.current) return;
setSaveStatus("saving");
try {
await updateMutationRef.current({
templateId,
title: titleRef.current,
icon: iconRef.current || undefined,
content: editor.getJSON(),
spaceId: spaceIdRef.current,
});
isDirtyRef.current = false;
setSaveStatus("saved");
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
savedFadeTimerRef.current = setTimeout(() => {
setSaveStatus((prev) => (prev === "saved" ? "idle" : prev));
}, 3000);
} catch {
setSaveStatus("error");
}
}, [editor, templateId]);
// Schedule save 30s after last change
const scheduleSave = useCallback(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
save();
}, 30000);
}, [save]);
// Mark content as dirty and schedule save
const markDirty = useCallback(() => {
isDirtyRef.current = true;
setSaveStatus("idle");
scheduleSave();
}, [scheduleSave]);
const handleTitleChange = useCallback(
(value: string) => {
setTitle(value);
titleRef.current = value;
if (loadedRef.current) markDirty();
},
[markDirty],
);
const handleIconChange = useCallback(
(value: string | null) => {
setIcon(value);
iconRef.current = value;
if (loadedRef.current) markDirty();
},
[markDirty],
);
const handleSpaceIdChange = useCallback(
(value: string | null) => {
setSpaceId(value);
spaceIdRef.current = value;
if (loadedRef.current) markDirty();
},
[markDirty],
);
// beforeunload warning for unsaved changes
// If user cancels (stays on page), the save fires and completes.
// If user leaves, the save is fire-and-forget.
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirtyRef.current) {
e.preventDefault();
e.returnValue = "";
save();
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [save]);
// Save on unmount if dirty
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
if (isDirtyRef.current) {
save();
}
};
}, [save]);
// Manual retry for error state
const handleRetry = useCallback(() => {
save();
}, [save]);
return (
<>
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
<div className={classes.header}>
<Container size={900} h="100%" px={0}>
<Group justify="space-between" h="100%" wrap="nowrap">
<Link to="/templates" className={classes.backLink}>
<IconArrowLeft size={16} />
{t("Templates")}
</Link>
<Group gap="xs" wrap="nowrap">
{saveStatus === "saving" && (
<Text size="xs" c="dimmed">
{t("Saving...")}
</Text>
)}
{saveStatus === "saved" && (
<Group gap={4} wrap="nowrap">
<IconCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("Saved")}
</Text>
</Group>
)}
{saveStatus === "error" && (
<Text
size="xs"
c="red"
style={{ cursor: "pointer" }}
onClick={handleRetry}
>
{t("Save failed. Retry")}
</Text>
)}
<Popover
width={300}
position="bottom"
shadow="md"
opened={settingsOpened}
onDismiss={closeSettings}
>
<Popover.Target>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => {
setDraftSpaceId(spaceId);
openSettings();
}}
>
<IconSettings size={18} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Select
label={t("Scope")}
description={t("Choose which space this template belongs to")}
data={spaceOptions}
value={draftSpaceId || ""}
onChange={(val) =>
setDraftSpaceId(val || null)
}
searchable
size="sm"
comboboxProps={{ withinPortal: false }}
/>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
size="xs"
onClick={closeSettings}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const scopeChanged = draftSpaceId !== spaceId;
handleSpaceIdChange(draftSpaceId);
closeSettings();
if (scopeChanged) {
notifications.show({
message: t("Template scope updated"),
});
}
}}
>
{t("Save")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
</Group>
</Container>
</div>
<Container size={900} className={classes.editor}>
<div className={classes.titleArea}>
<div className={classes.emojiButton}>
<EmojiPicker
onEmojiSelect={(emoji: { native: string }) =>
handleIconChange(emoji.native)
}
icon={
icon ? (
<span className={classes.emojiIcon}>{icon}</span>
) : (
<IconMoodSmile size={20} stroke={1.5} />
)
}
removeEmojiAction={() =>
handleIconChange(null)
}
readOnly={false}
actionIconProps={icon ? { size: "3rem", variant: "transparent" } : undefined}
/>
</div>
<input
className={classes.titleInput}
placeholder={t("Untitled")}
autoFocus
value={title}
onChange={(e) =>
handleTitleChange(e.currentTarget.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
editor?.commands.focus("start");
}
}}
/>
{existingTemplate && (
<TemplateMeta template={existingTemplate} />
)}
</div>
<EditorContent editor={editor} />
<div style={{ paddingBottom: "20vh" }} />
</Container>
</>
);
}
@@ -0,0 +1,213 @@
import { useState } from "react";
import {
Container,
Title,
Group,
Button,
SimpleGrid,
Select,
Text,
Center,
Skeleton,
Card,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
} from "@/ee/template/queries/template-query";
import TemplateCard from "@/ee/template/components/template-card";
import { ITemplate } from "@/ee/template/types/template.types";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
export default function TemplateList() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const [spaceFilter, setSpaceFilter] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<ITemplate | null>(
null,
);
const [useModalOpened, { open: openUseModal, close: closeUseModal }] =
useDisclosure(false);
const [previewOpened, { open: openPreview, close: closePreview }] =
useDisclosure(false);
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] =
useDisclosure(false);
const {
data,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useGetTemplatesQuery({
spaceId: spaceFilter || undefined,
});
const templates = data?.pages.flatMap((p) => p.items) ?? [];
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
const deleteTemplateMutation = useDeleteTemplateMutation();
const spaceOptions = [
{ value: "", label: t("All templates") },
...(spaces?.items?.map((s) => ({ value: s.id, label: s.name })) || []),
];
const spaceNameMap = new Map(
spaces?.items?.map((s) => [s.id, s.name]) || [],
);
const handlePreview = (template: ITemplate) => {
setSelectedTemplate(template);
openPreview();
};
const handleUse = (template: ITemplate) => {
setSelectedTemplate(template);
closePreview();
openUseModal();
};
const handleEdit = (template: ITemplate) => {
navigate(`/templates/${template.id}`);
};
const handleDelete = (template: ITemplate) => {
modals.openConfirmModal({
title: t("Are you sure you want to delete this template?"),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteTemplateMutation.mutate(template.id),
});
};
return (
<>
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
<Title order={3}>{t("Templates")}</Title>
{isWorkspaceAdmin && (
<Button
leftSection={<IconPlus size={16} />}
onClick={openCreateModal}
>
{t("New template")}
</Button>
)}
</Group>
<Group mb="lg">
<Select
data={spaceOptions}
value={spaceFilter || ""}
onChange={(val) => setSpaceFilter(val || null)}
placeholder={t("Filter by space")}
clearable={false}
searchable
size="sm"
w={220}
comboboxProps={{ width: "target" }}
/>
</Group>
{isLoading ? (
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} radius="md" padding="lg" style={{ boxShadow: "rgba(0, 0, 0, 0.07) 0px 2px 45px 4px" }}>
<Group justify="space-between" align="flex-start" mb="md">
<Skeleton width={36} height={36} radius="md" />
</Group>
<Skeleton height={14} width="70%" mb={8} />
<Skeleton height={10} width="50%" mb="sm" />
<Group justify="space-between" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)", marginTop: "auto" }}>
<Skeleton height={20} width={60} radius="xl" />
<Group gap={6}>
<Skeleton height={18} circle />
<Skeleton height={10} width={80} />
</Group>
</Group>
</Card>
))}
</SimpleGrid>
) : templates.length ? (
<>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
{templates.map((template) => (
<TemplateCard
key={template.id}
template={template}
spaceName={
template.spaceId
? spaceNameMap.get(template.spaceId)
: undefined
}
onUse={handlePreview}
onEdit={handleEdit}
onDelete={handleDelete}
canManage={isWorkspaceAdmin}
/>
))}
</SimpleGrid>
{hasNextPage && (
<Button
variant="subtle"
fullWidth
mt="sm"
mb="xl"
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
>
{t("Load more")}
</Button>
)}
</>
) : (
<Center py="xl">
<Text c="dimmed">{t("No templates found")}</Text>
</Center>
)}
</Container>
<CreateTemplateModal
opened={createModalOpened}
onClose={closeCreateModal}
/>
{selectedTemplate && (
<>
<TemplatePreviewModal
templateId={selectedTemplate.id}
opened={previewOpened}
onClose={closePreview}
onUse={() => handleUse(selectedTemplate)}
onEdit={isWorkspaceAdmin ? () => handleEdit(selectedTemplate) : undefined}
/>
<UseTemplateModal
template={selectedTemplate}
opened={useModalOpened}
onClose={closeUseModal}
/>
</>
)}
</>
);
}
@@ -0,0 +1,167 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
InfiniteData,
} from "@tanstack/react-query";
import {
getTemplates,
getTemplateById,
createTemplate,
updateTemplate,
deleteTemplate,
useTemplate,
} from "@/ee/template/services/template-service.ts";
import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
const { spaceId } = params ?? {};
return useInfiniteQuery({
queryKey: ["templates", { spaceId }],
queryFn: ({ pageParam }) =>
getTemplates({ spaceId, cursor: pageParam, limit: 30 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
export function useGetTemplateByIdQuery(
templateId: string,
): UseQueryResult<ITemplate, Error> {
return useQuery({
queryKey: ["template", templateId],
queryFn: () => getTemplateById(templateId),
enabled: !!templateId,
});
}
export function useCreateTemplateMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<ITemplate, Error, Partial<ITemplate>>({
mutationFn: (data) => createTemplate(data),
onSuccess: (newTemplate) => {
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
{ queryKey: ["templates"] },
(old) => {
if (!old) return old;
const firstPage = old.pages[0];
return {
...old,
pages: [
{ ...firstPage, items: [newTemplate, ...firstPage.items] },
...old.pages.slice(1),
],
};
},
);
notifications.show({ message: t("Template created successfully") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to create template"),
color: "red",
});
},
});
}
export function useUpdateTemplateMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
ITemplate,
Error,
Partial<ITemplate> & { templateId: string }
>({
mutationFn: (data) => updateTemplate(data),
onSuccess: (updatedTemplate) => {
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
{ queryKey: ["templates"] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === updatedTemplate.id ? updatedTemplate : item,
),
})),
};
},
);
queryClient.setQueryData(
["template", updatedTemplate.id],
updatedTemplate,
);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update template"),
color: "red",
});
},
});
}
export function useDeleteTemplateMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (templateId) => deleteTemplate(templateId),
onSuccess: (_data, templateId) => {
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
{ queryKey: ["templates"] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== templateId),
})),
};
},
);
notifications.show({ message: t("Template deleted") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to delete template"),
color: "red",
});
},
});
}
export function useUseTemplateMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}) => useTemplate(data),
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to create page from template"),
color: "red",
});
},
});
}
@@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts";
export async function getTemplates(params?: {
spaceId?: string;
cursor?: string;
limit?: number;
}): Promise<IPagination<ITemplate>> {
const req = await api.post("/templates", params);
return req.data;
}
export async function getTemplateById(
templateId: string,
): Promise<ITemplate> {
const req = await api.post<ITemplate>("/templates/info", { templateId });
return req.data;
}
export async function createTemplate(
data: Partial<ITemplate>,
): Promise<ITemplate> {
const req = await api.post<ITemplate>("/templates/create", data);
return req.data;
}
export async function updateTemplate(
data: Partial<ITemplate> & { templateId: string },
): Promise<ITemplate> {
const req = await api.post<ITemplate>("/templates/update", data);
return req.data;
}
export async function deleteTemplate(templateId: string): Promise<void> {
await api.post<void>("/templates/delete", { templateId });
}
export async function useTemplate(data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}): Promise<any> {
const req = await api.post("/templates/use", data);
return req.data;
}
@@ -0,0 +1,18 @@
export interface ITemplate {
id: string;
title: string;
description?: string;
content?: any;
icon?: string;
spaceId?: string;
workspaceId: string;
creatorId: string;
lastUpdatedById?: string;
creator?: {
id: string;
name: string;
avatarUrl?: string;
};
createdAt: string;
updatedAt: string;
}
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
export type YjsSelection = {
anchor: any;
head: any;
};
export type ReadOnlyCommentData = {
yjsSelection: YjsSelection;
selectedText: string;
};
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
@@ -6,6 +6,8 @@ import {
activeCommentIdAtom,
draftCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
@@ -19,12 +21,15 @@ import { useTranslation } from "react-i18next";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
pageId: string;
readOnly?: boolean;
}
function CommentDialog({ editor, pageId }: CommentDialogProps) {
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom);
@@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation;
const isPending = createCommentMutation.isPending;
const handleDialogClose = () => {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
if (readOnly) {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
}
};
const getSelectedText = () => {
@@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
};
const handleAddComment = async () => {
if (readOnly) {
await handleAddReadOnlyComment();
return;
}
try {
const selectedText = getSelectedText();
const commentData = {
@@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run();
setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true });
@@ -85,6 +100,33 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
}
};
const handleAddReadOnlyComment = async () => {
if (!readOnlyCommentData) return;
try {
const createdComment = await createCommentMutation.mutateAsync({
pageId,
content: JSON.stringify(comment),
selection: readOnlyCommentData.selectedText,
type: "inline",
yjsSelection: readOnlyCommentData.yjsSelection,
});
setActiveCommentId(createdComment.id);
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
} finally {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
}
};
const handleCommentEditorChange = (newContent: any) => {
setComment(newContent);
};
@@ -44,7 +44,9 @@ function CommentListWithTabs() {
const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canComment = page?.permissions?.canEdit ?? false;
const canComment =
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -153,7 +155,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role],
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
);
if (isCommentsLoading) {
@@ -75,7 +75,7 @@ function CommentMenu({
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={upgradeLabel} position="left">
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
{t("Resolve comment")}
</Menu.Item>
@@ -65,6 +65,11 @@ export function useCreateCommentMutation() {
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache && cache.pages.length > 0) {
const alreadyExists = cache.pages.some((page) =>
page.items.some((c) => c.id === newComment.id),
);
if (alreadyExists) return;
const lastIdx = cache.pages.length - 1;
queryClient.setQueryData(RQ_KEY(newComment.pageId), {
...cache,
@@ -17,6 +17,10 @@ export interface IComment {
deletedAt?: Date;
creator: IUser;
resolvedBy?: IUser;
yjsSelection?: {
anchor: any;
head: any;
};
}
export interface ICommentData {
@@ -1,17 +1,43 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { Group, Text, Paper, ActionIcon, Loader, Tooltip } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { editor, node, getPos, selected } = props;
const { url, name, size, mime, attachmentId, placeholder } = node.attrs;
const { hovered, ref } = useHover();
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
const handleEmbedAsPdf = useCallback(() => {
const pos = getPos();
if (pos === undefined || !url) return;
const nodeSize = node.nodeSize;
editor
.chain()
.insertContentAt(
{ from: pos, to: pos + nodeSize },
{
type: "pdf",
attrs: {
src: url,
name,
attachmentId,
size,
},
},
)
.run();
}, [editor, getPos, node, url, name, attachmentId]);
return (
<NodeViewWrapper>
<Paper withBorder p="4px" ref={ref} data-drag-handle>
@@ -23,14 +49,14 @@ export default function AttachmentView(props: NodeViewProps) {
h={25}
>
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
{!url && placeholder ? (
<Loader size={20} style={{ flexShrink: 0 }} />
) : (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })}
{!url && placeholder ? t("Uploading {{name}}", { name }) : name}
</Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
</Group>
{url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{isPdf && editor.isEditable && (
<Tooltip label={t("Embed as PDF")} position="top" withinPortal={false}>
<ActionIcon variant="default" aria-label={t("Embed as PDF")} onClick={handleEmbedAsPdf}>
<IconFileTypePdf size={18} />
</ActionIcon>
</Tooltip>
)}
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
</Group>
)}
</Group>
</Paper>
@@ -0,0 +1,123 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconDownload,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import classes from "../common/toolbar-menu.module.css";
export function AudioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const audioAttrs = ctx.editor.getAttributes("audio");
return {
isAudio: ctx.editor.isActive("audio"),
src: audioAttrs?.src || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("audio") && editor.getAttributes("audio").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default AudioMenu;
@@ -0,0 +1,37 @@
.audioWrapper {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.audio {
display: block;
width: 100%;
border-radius: 8px;
}
@@ -0,0 +1,68 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import { isInternalFileUrl } from "@docmost/editor-ext";
import classes from "./audio-view.module.css";
import { useTranslation } from "react-i18next";
export default function AudioView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node } = props;
const { src, placeholder } = node.attrs;
const safeSrc = useMemo(() => {
if (!src || !isInternalFileUrl(src)) return null;
return getFileUrl(src);
}, [src]);
const previewSrc = useMemo(() => {
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.audioPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div className={`${classes.audioWrapper} ${!safeSrc && placeholder ? classes.skeleton : ''}`}>
{safeSrc && (
<audio
className={classes.audio}
preload="metadata"
controls
src={safeSrc}
/>
)}
{!safeSrc && previewSrc && (
<Group pos="relative" w="100%">
<audio
className={classes.audio}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!safeSrc && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
{!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls />
)}
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,36 @@
import { handleAudioUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "@/i18n.ts";
export const uploadAudioAction = handleAudioUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (!file.type.includes("audio/")) {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});
@@ -0,0 +1,159 @@
import type { Editor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import {
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import { useTranslation } from "react-i18next";
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
type ReadonlyBubbleMenuProps = {
editor: Editor;
};
export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
const { t } = useTranslation();
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
showReadOnlyCommentPopupAtom,
);
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const menuRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const isInteractingRef = useRef(false);
const updateMenuPosition = useCallback(() => {
if (isInteractingRef.current) return;
const pmSelection = editor.state.selection;
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
setVisible(false);
return;
}
const selection = window.getSelection();
if (
!selection ||
selection.isCollapsed ||
selection.rangeCount === 0 ||
showReadOnlyCommentPopup
) {
setVisible(false);
return;
}
const editorDom = editor.view.dom;
if (
!editorDom.contains(selection.anchorNode) ||
!editorDom.contains(selection.focusNode)
) {
setVisible(false);
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width === 0) {
setVisible(false);
return;
}
const editorRect = editorDom
.closest(".editor-container")
?.getBoundingClientRect();
if (!editorRect) {
setVisible(false);
return;
}
setPosition({
top: rect.top - editorRect.top - 44,
left: rect.left - editorRect.left + rect.width / 2,
});
setVisible(true);
}, [editor, showReadOnlyCommentPopup]);
useEffect(() => {
const handleSelectionChange = () => {
updateMenuPosition();
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, [updateMenuPosition]);
useEffect(() => {
if (showReadOnlyCommentPopup) {
setVisible(false);
}
}, [showReadOnlyCommentPopup]);
const handleCommentClick = () => {
if (!editor) return;
const view = editor.view;
const ystate = ySyncPluginKey.getState(view.state);
if (ystate?.binding) {
const selection = getRelativeSelection(ystate.binding, view.state);
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
// @ts-ignore
setReadOnlyCommentData({
yjsSelection: {
anchor: selection.anchor,
head: selection.head,
},
selectedText,
});
setShowReadOnlyCommentPopup(true);
setVisible(false);
}
};
if (!visible) return null;
return (
<div
ref={menuRef}
style={{
position: "absolute",
top: position.top,
left: position.left,
transform: "translateX(-50%)",
zIndex: 199,
}}
>
<div className={classes.bubbleMenu}>
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t("Comment")}
style={{ border: "none" }}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
isInteractingRef.current = true;
handleCommentClick();
isInteractingRef.current = false;
}}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</div>
);
};
@@ -1,6 +1,7 @@
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
@@ -12,6 +13,8 @@ import {
const ATTACHMENT_NODE_TYPES = [
"image",
"video",
"audio",
"pdf",
"attachment",
"excalidraw",
"drawio",
@@ -63,6 +66,7 @@ export const handlePaste = (
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadPdfAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
}
return true;
@@ -229,6 +233,7 @@ export const handleFileDrop = (
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
@@ -1,5 +1,5 @@
import type { ResizableNodeViewDirection } from "@tiptap/core";
import classes from "./node-resize.module.css";
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
export function createResizeHandle(
direction: ResizableNodeViewDirection,
@@ -20,8 +20,8 @@
.cornerHandle {
position: absolute;
width: 36px;
height: 36px;
width: 24px;
height: 24px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
@@ -42,13 +42,13 @@
}
&::before {
width: 28px;
width: 20px;
height: 3px;
}
&::after {
width: 3px;
height: 28px;
height: 20px;
}
&:hover::before,
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
useEffect(() => {
if (!dragRef.current && wrapperRef.current) {
widthRef.current = initialWidth;
heightRef.current = initialHeight;
wrapperRef.current.style.width = `${initialWidth}px`;
wrapperRef.current.style.height = `${initialHeight}px`;
}
}, [initialWidth, initialHeight]);
const handleMouseMove = useRef((e: MouseEvent) => {
const drag = dragRef.current;
if (!drag || !wrapperRef.current) return;
@@ -86,8 +86,8 @@ export default function EmbedView(props: NodeViewProps) {
{embedUrl ? (
<div className={classes.embedContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 640}
initialHeight={nodeHeight || 480}
initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 600}
minWidth={200}
maxWidth={1200}
minHeight={200}
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
<iframe
className={classes.embedIframe}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allowFullScreen
frameBorder="0"
/>
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
@@ -33,6 +33,7 @@ export default function ImageView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
!src && placeholder && classes.skeleton,
alignClass,
)}
style={{
@@ -54,7 +55,7 @@ export default function ImageView(props: NodeViewProps) {
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
{!src && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
@@ -62,7 +62,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
query: props.query,
includeUsers: true,
includePages: true,
spaceId: space.id,
spaceId: space?.id,
limit: props.query ? 10 : 5,
preload: true,
});
@@ -294,6 +294,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
w={popupWidth}
scrollbars={"y"}
scrollbarSize={6}
overscrollBehavior={"contain"}
styles={{ content: { minWidth: 0 } }}
>
{renderItems?.map((item, index) => {
@@ -53,8 +53,8 @@ const mentionRenderItems = () => {
const editorDom = props.editor?.view?.dom;
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
const dialogEl = editorDom?.closest("[data-comment-dialog]");
const isInCommentContext = !!(asideEl || dialogEl);
// const isInCommentContext = !!asideEl;
const chatInput = editorDom?.closest("[data-chat-input]");
const isInCommentContext = !!(asideEl || dialogEl || chatInput);
component = new ReactRenderer(MentionList, {
props: { ...props, isInCommentContext },
@@ -0,0 +1,145 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconPaperclip,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
export function PdfMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const pdfAttrs = ctx.editor.getAttributes("pdf");
return {
isPdf: ctx.editor.isActive("pdf"),
src: pdfAttrs?.src || null,
name: pdfAttrs?.name || null,
attachmentId: pdfAttrs?.attachmentId || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) {
return false;
}
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
if (!dom) return false;
return !!dom.querySelector("[data-pdf-error]");
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const handleConvertToAttachment = useCallback(() => {
if (!editorState?.src) return;
const { selection } = editor.state;
const { from } = selection;
const node = editor.state.doc.nodeAt(from);
if (!node || node.type.name !== "pdf") return;
editor
.chain()
.insertContentAt(
{ from, to: from + node.nodeSize },
{
type: "attachment",
attrs: {
url: node.attrs.src,
name: node.attrs.name,
attachmentId: node.attrs.attachmentId,
size: node.attrs.size,
mime: "application/pdf",
},
},
)
.run();
}, [editor, editorState]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`pdf-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Convert to attachment")} withinPortal={false}>
<ActionIcon
onClick={handleConvertToAttachment}
size="lg"
aria-label={t("Convert to attachment")}
variant="subtle"
>
<IconPaperclip size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default PdfMenu;
@@ -0,0 +1,100 @@
.pdfWrapper {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.pdfContainer {
display: flex;
justify-content: center;
}
.pdfResizeWrapper {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.pdfIframe {
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
}
.hoverMenu {
position: absolute;
top: 56px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s ease;
background-color: rgba(0, 0, 0, 0.5);
}
.hoverMenu::before {
content: "";
position: absolute;
inset: -12px;
}
.hoverMenu:hover {
opacity: 1;
}
.pdfResizeWrapper:hover .hoverMenu {
opacity: 1;
}
.pdfError {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 32px;
border-radius: 8px;
cursor: pointer;
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
@@ -0,0 +1,170 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Loader, Text, Tooltip } from "@mantine/core";
import { useCallback, useMemo, useState } from "react";
import { getFileUrl } from "@/lib/config.ts";
import { ResizableWrapper } from "../common/resizable-wrapper";
import clsx from "clsx";
import classes from "./pdf-view.module.css";
import { useTranslation } from "react-i18next";
import { isInternalFileUrl } from "@docmost/editor-ext";
import {
IconFileTypePdf,
IconPaperclip,
IconTrash,
} from "@tabler/icons-react";
export default function PdfView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, getPos, selected, updateAttributes } = props;
const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs;
const [hasError, setHasError] = useState(false);
const safeSrc = useMemo(() => {
if (!src || !isInternalFileUrl(src)) return null;
return getFileUrl(src);
}, [src]);
const handleSelect = useCallback(() => {
const pos = getPos();
if (pos !== undefined) {
editor.commands.setNodeSelection(pos);
}
}, [editor, getPos]);
const handleResize = useCallback(
(newWidth: number, newHeight: number) => {
updateAttributes({ width: newWidth, height: newHeight });
},
[updateAttributes],
);
const handleConvertToAttachment = useCallback(() => {
if (!src) return;
const pos = getPos();
if (pos === undefined) return;
const currentNode = editor.state.doc.nodeAt(pos);
if (!currentNode || currentNode.type.name !== "pdf") return;
editor
.chain()
.insertContentAt(
{ from: pos, to: pos + currentNode.nodeSize },
{
type: "attachment",
attrs: {
url: currentNode.attrs.src,
name: currentNode.attrs.name,
attachmentId: currentNode.attrs.attachmentId,
size: currentNode.attrs.size,
mime: "application/pdf",
},
},
)
.run();
}, [editor, src, getPos]);
const handleDelete = useCallback(() => {
const pos = getPos();
if (pos === undefined) return;
editor.commands.setNodeSelection(pos);
editor.commands.deleteSelection();
}, [editor, getPos]);
if (!src || !safeSrc) {
return (
<NodeViewWrapper data-drag-handle>
<div className={`${classes.pdfWrapper} ${placeholder ? classes.skeleton : ''}`} style={{ height: placeholder ? 600 : undefined }}>
{placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper>
);
}
if (hasError) {
return (
<NodeViewWrapper data-drag-handle>
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
<IconFileTypePdf size={32} stroke={1.5} />
<Text size="sm" c="dimmed">
{t("Failed to load PDF")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle className={classes.pdfNodeView}>
<div className={classes.pdfContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 600}
minWidth={200}
maxWidth={1200}
minHeight={200}
maxHeight={1200}
onResize={handleResize}
isEditable={editor.isEditable}
selected={selected}
className={clsx(classes.pdfResizeWrapper, {
"ProseMirror-selectednode": selected,
})}
>
<iframe
className={classes.pdfIframe}
src={safeSrc}
loading="lazy"
frameBorder="0"
onError={() => setHasError(true)}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const status = iframe.contentDocument?.querySelector("pre")?.textContent;
if (status && status.includes('"statusCode":404')) {
setHasError(true);
}
} catch {
// cross-origin - can't inspect, assume OK
}
}}
/>
{editor.isEditable && (
<div className={classes.hoverMenu}>
<Tooltip position="top" label={t("Convert to attachment")} withinPortal>
<ActionIcon
size="sm"
variant="filled"
color="dark"
onClick={handleConvertToAttachment}
aria-label={t("Convert to attachment")}
>
<IconPaperclip size={14} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal>
<ActionIcon
size="sm"
variant="filled"
color="dark"
onClick={handleDelete}
aria-label={t("Delete")}
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</div>
)}
</ResizableWrapper>
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,36 @@
import { handlePdfUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "@/i18n.ts";
export const uploadPdfAction = handlePdfUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (file.type !== "application/pdf") {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});
@@ -87,7 +87,13 @@ const CommandList = ({
return flatItems.length > 0 ? (
<Paper id="slash-command" shadow="md" p="xs" withBorder>
<ScrollArea viewportRef={viewportRef} h={350} w={270} scrollbarSize={8}>
<ScrollArea
viewportRef={viewportRef}
h={350}
w={270}
scrollbarSize={8}
overscrollBehavior="contain"
>
{Object.entries(items).map(([category, categoryItems]) => (
<div key={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
@@ -103,10 +109,7 @@ const CommandList = ({
})}
>
<Group>
<ActionIcon
variant="default"
component="div"
>
<ActionIcon variant="default" component="div">
<item.icon size={18} />
</ActionIcon>
@@ -12,7 +12,9 @@ import {
IconMath,
IconMathFunction,
IconMovie,
IconMusic,
IconPaperclip,
IconFileTypePdf,
IconPhoto,
IconTable,
IconTypography,
@@ -30,7 +32,9 @@ import {
} from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
@@ -161,7 +165,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Image",
description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"],
searchTerms: ["photo", "picture", "media", "file", "attachment"],
icon: IconPhoto,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -194,7 +198,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Video",
description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"],
searchTerms: ["video", "mp4", "media", "file", "attachment"],
icon: IconMovie,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -224,10 +228,74 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click();
},
},
{
title: "Audio",
description: "Upload any audio from your device.",
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
icon: IconMusic,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload audio
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAudioAction(file, editor, pos, pageId);
}
}
input.remove();
};
input.click();
},
},
{
title: "Embed PDF",
description: "Upload and embed a PDF file.",
searchTerms: ["pdf", "document", "embed"],
icon: IconFileTypePdf,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf";
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadPdfAction(file, editor, pos, pageId);
}
}
input.remove();
};
input.click();
},
},
{
title: "File attachment",
description: "Upload any file from your device.",
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
searchTerms: ["file", "attachment", "upload", "csv", "zip"],
icon: IconPaperclip,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -351,7 +419,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run(),
},
{
title: "Draw.io (diagrams.net) ",
title: "Draw.io (diagrams.net)",
description: "Insert and design Drawio diagrams",
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
icon: IconDrawio,
@@ -359,7 +427,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).setDrawio().run(),
},
{
title: "Excalidraw diagram",
title: "Excalidraw (Whiteboard)",
description: "Draw and sketch excalidraw diagrams",
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
icon: IconExcalidraw,
@@ -548,7 +616,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "YouTube",
description: "Embed YouTube video",
searchTerms: ["youtube", "yt"],
searchTerms: ["youtube", "yt", "media", "video"],
icon: YoutubeIcon,
command: ({ editor, range }: CommandProps) => {
editor
@@ -620,8 +688,10 @@ const CommandGroups: SlashMenuGroupedItemsType = {
export const getSuggestionItems = ({
query,
excludeItems,
}: {
query: string;
excludeItems?: Set<string>;
}): SlashMenuGroupedItemsType => {
const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {};
@@ -638,6 +708,7 @@ export const getSuggestionItems = ({
for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false;
return (
fuzzyMatch(search, item.title) ||
item.description.toLowerCase().includes(search) ||
@@ -647,7 +718,11 @@ export const getSuggestionItems = ({
});
if (filteredItems.length) {
filteredGroups[group] = filteredItems;
filteredGroups[group] = filteredItems.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
return aTitle - bTitle;
});
}
}

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