mirror of
https://github.com/docmost/docmost.git
synced 2026-05-11 00:44:07 +08:00
Compare commits
22 Commits
chat
..
editor-279
| Author | SHA1 | Date | |
|---|---|---|---|
| 1adbf97701 | |||
| 4ff67bb962 | |||
| 2e392da61a | |||
| 12e9cae65c | |||
| 477c8ace52 | |||
| d8ec0472ef | |||
| c2b41d72bf | |||
| 963ab5d7cb | |||
| bdde4a7178 | |||
| 2c35d2b3f4 | |||
| 7681894953 | |||
| bfaef88429 | |||
| c6bbb57406 | |||
| c599b6a9c1 | |||
| 4b99d89e55 | |||
| aabdc7264d | |||
| 4ebcbb71da | |||
| c07f348b38 | |||
| a5360ad341 | |||
| 795b79c2a2 | |||
| 6b2f8542c4 | |||
| 9f38c61882 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.71.1",
|
||||
"version": "0.70.3",
|
||||
"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.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"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.5"
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,6 @@
|
||||
"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",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Account": "Account",
|
||||
"Account": "Account ",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
"Add": "Add.",
|
||||
"Add group members": "Add group members",
|
||||
"Add groups": "Add groups",
|
||||
"Add members": "Add members",
|
||||
@@ -44,24 +44,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 emails addresses",
|
||||
"enter valid emails addresses": "Enter valid email 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",
|
||||
@@ -87,7 +87,7 @@
|
||||
"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",
|
||||
@@ -113,7 +113,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",
|
||||
@@ -149,56 +149,56 @@
|
||||
"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.",
|
||||
"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 +222,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 +241,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 +251,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 +267,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 +312,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",
|
||||
@@ -345,41 +345,41 @@
|
||||
"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",
|
||||
"Audio": "Audio",
|
||||
"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",
|
||||
"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",
|
||||
@@ -389,27 +389,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",
|
||||
@@ -418,40 +418,37 @@
|
||||
"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",
|
||||
@@ -464,135 +461,135 @@
|
||||
"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 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 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",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -627,7 +624,6 @@
|
||||
"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)",
|
||||
@@ -671,32 +667,10 @@
|
||||
"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> 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": "You’re now watching this page",
|
||||
"You are no longer watching this page": "You’re no longer watching this page",
|
||||
"You are now watching this space": "You’re now watching this space",
|
||||
"You are no longer watching this space": "You’re no longer watching this space",
|
||||
"Direct": "Direct",
|
||||
"Updates": "Updates",
|
||||
"<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.",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"This week": "This week",
|
||||
@@ -731,57 +705,32 @@
|
||||
"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 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?"
|
||||
"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.",
|
||||
"Uploading {{name}}": "Uploading {{name}}",
|
||||
"Uploading file": "Uploading file"
|
||||
}
|
||||
|
||||
@@ -341,7 +341,6 @@
|
||||
"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",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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},{",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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",
|
||||
@@ -422,7 +415,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 des matières.",
|
||||
"Table of contents": "",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
|
||||
"Share": "Partager",
|
||||
"Public sharing": "Partage public",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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.",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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": "ファイルをアップロード中",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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": "コールアウト",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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": "ページのアクセス権",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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": "파일 업로드 중",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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": "경고 상자",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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": "페이지 권한},{",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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},{",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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": "Загрузка файла",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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": "Выноска",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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": "Права доступа к странице},{",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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": "Завантаження файлу",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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": "Виноска",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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": "Права доступу до сторінки.",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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,7 +341,6 @@
|
||||
"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": "正在上传文件",
|
||||
@@ -352,12 +351,6 @@
|
||||
"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": "标注块",
|
||||
@@ -449,9 +442,6 @@
|
||||
"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": "页面权限},{",
|
||||
@@ -674,24 +664,6 @@
|
||||
"<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": "本周",
|
||||
|
||||
@@ -38,7 +38,6 @@ 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 AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
|
||||
export default function App() {
|
||||
@@ -82,8 +81,6 @@ 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={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
@@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CopyProps {
|
||||
text: string;
|
||||
size?: MantineSize;
|
||||
color?: MantineColor;
|
||||
}
|
||||
export default function CopyTextButton({ text, size }: CopyProps) {
|
||||
export default function CopyTextButton({ text }: CopyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -24,7 +22,6 @@ export default function CopyTextButton({ text, size }: CopyProps) {
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
size={size}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -7,19 +7,6 @@
|
||||
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;
|
||||
@@ -29,9 +16,6 @@
|
||||
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,18 +1,8 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Badge, Group, Text, Tooltip } 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, useLocation } from "react-router-dom";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
@@ -33,11 +23,8 @@ 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();
|
||||
@@ -47,14 +34,9 @@ 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 isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
const items = links.map((link) => (
|
||||
@@ -91,24 +73,15 @@ export function AppHeader() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
|
||||
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
@@ -125,49 +98,6 @@ 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,7 +7,6 @@ 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);
|
||||
@@ -26,10 +25,6 @@ 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;
|
||||
@@ -39,14 +34,12 @@ export default function Aside() {
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
{tab !== "chat" && (
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
)}
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
|
||||
{tab === "comments" || tab === "chat" ? (
|
||||
component
|
||||
{tab === "comments" ? (
|
||||
<CommentListWithTabs />
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -73,7 +72,6 @@ export default function GlobalAppShell({
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isAiRoute = location.pathname.startsWith("/ai");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
@@ -110,10 +108,9 @@ export default function GlobalAppShell({
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
{!isAiRoute && <div className={classes.resizeHandle} onMouseDown={startResizing} />}
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
<AppShell.Main>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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 type { AiChat } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-sidebar.module.css";
|
||||
|
||||
type ChatGroup = { key: string; label: string; chats: AiChat[] };
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -72,7 +71,6 @@ export default function AiSettings() {
|
||||
<Stack gap="md">
|
||||
{!isCloud() && <EnableAiSearch />}
|
||||
<EnableGenerativeAi />
|
||||
<EnableAiChat />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -16,5 +16,4 @@ export const Feature = {
|
||||
AUDIT_LOGS: 'audit:logs',
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
} as const;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,3 @@ 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,8 +6,6 @@ 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";
|
||||
@@ -21,15 +19,12 @@ import { useTranslation } from "react-i18next";
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
pageId: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
function CommentDialog({ editor, pageId }: 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);
|
||||
@@ -39,17 +34,11 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
handleDialogClose();
|
||||
});
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const isPending = createCommentMutation.isPending;
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const handleDialogClose = () => {
|
||||
if (readOnly) {
|
||||
setShowReadOnlyCommentPopup(false);
|
||||
// @ts-ignore
|
||||
setReadOnlyCommentData(null);
|
||||
} else {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
}
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
@@ -58,11 +47,6 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (readOnly) {
|
||||
await handleAddReadOnlyComment();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedText = getSelectedText();
|
||||
const commentData = {
|
||||
@@ -81,6 +65,7 @@ function CommentDialog({ editor, pageId, readOnly }: 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 });
|
||||
@@ -100,33 +85,6 @@ function CommentDialog({ editor, pageId, readOnly }: 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,9 +44,7 @@ function CommentListWithTabs() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canComment =
|
||||
(page?.permissions?.canEdit ?? false) ||
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
const canComment = page?.permissions?.canEdit ?? false;
|
||||
|
||||
// Separate active and resolved comments
|
||||
const { activeComments, resolvedComments } = useMemo(() => {
|
||||
@@ -155,7 +153,7 @@ function CommentListWithTabs() {
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
|
||||
@@ -75,7 +75,7 @@ function CommentMenu({
|
||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
|
||||
<Tooltip label={upgradeLabel} position="left">
|
||||
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
|
||||
{t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -65,11 +65,6 @@ 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,10 +17,6 @@ export interface IComment {
|
||||
deletedAt?: Date;
|
||||
creator: IUser;
|
||||
resolvedBy?: IUser;
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useCallback } from "react";
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, getPos, selected } = props;
|
||||
const { url, name, size, mime, attachmentId, placeholder } = node.attrs;
|
||||
const { url, name, size, mime, attachmentId } = node.attrs;
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
|
||||
@@ -49,14 +49,14 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
h={25}
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||
{!url && placeholder ? (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
{url ? (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||
{!url && placeholder ? t("Uploading {{name}}", { name }) : name}
|
||||
{url ? name : t("Uploading {{name}}", { name })}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AudioView(props: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={`${classes.audioWrapper} ${!safeSrc && placeholder ? classes.skeleton : ''}`}>
|
||||
<div className={`${classes.audioWrapper} ${!safeSrc ? classes.skeleton : ''}`}>
|
||||
{safeSrc && (
|
||||
<audio
|
||||
className={classes.audio}
|
||||
@@ -49,7 +49,7 @@ export default function AudioView(props: NodeViewProps) {
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!safeSrc && !previewSrc && placeholder && (
|
||||
{!safeSrc && !previewSrc && (
|
||||
<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">
|
||||
@@ -59,9 +59,6 @@ export default function AudioView(props: NodeViewProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!safeSrc && !previewSrc && !placeholder && (
|
||||
<audio className={classes.audio} controls />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -33,7 +33,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.imageWrapper,
|
||||
!src && placeholder && classes.skeleton,
|
||||
!src && classes.skeleton,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
@@ -55,7 +55,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && placeholder && (
|
||||
{!src && !previewSrc && (
|
||||
<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,7 +294,6 @@ 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 chatInput = editorDom?.closest("[data-chat-input]");
|
||||
const isInCommentContext = !!(asideEl || dialogEl || chatInput);
|
||||
const isInCommentContext = !!(asideEl || dialogEl);
|
||||
// const isInCommentContext = !!asideEl;
|
||||
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props: { ...props, isInCommentContext },
|
||||
|
||||
@@ -73,17 +73,15 @@ export default function PdfView(props: NodeViewProps) {
|
||||
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 className={`${classes.pdfWrapper} ${classes.skeleton}`} style={{ height: 600 }}>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -87,13 +87,7 @@ const CommandList = ({
|
||||
|
||||
return flatItems.length > 0 ? (
|
||||
<Paper id="slash-command" shadow="md" p="xs" withBorder>
|
||||
<ScrollArea
|
||||
viewportRef={viewportRef}
|
||||
h={350}
|
||||
w={270}
|
||||
scrollbarSize={8}
|
||||
overscrollBehavior="contain"
|
||||
>
|
||||
<ScrollArea viewportRef={viewportRef} h={350} w={270} scrollbarSize={8}>
|
||||
{Object.entries(items).map(([category, categoryItems]) => (
|
||||
<div key={category}>
|
||||
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||
@@ -109,7 +103,10 @@ const CommandList = ({
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<ActionIcon variant="default" component="div">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
component="div"
|
||||
>
|
||||
<item.icon size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const renderItems = () => {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.zIndex = "199";
|
||||
popup.style.zIndex = "9999";
|
||||
popup.style.position = "absolute";
|
||||
popup.style.top = "0";
|
||||
popup.style.left = "0";
|
||||
|
||||
@@ -34,7 +34,7 @@ export const TableMenu = React.memo(
|
||||
if (isTextSelected(editor)) return false;
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
@@ -121,11 +121,7 @@ export const TableMenu = React.memo(
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add left column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Add left column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnLeft}
|
||||
variant="subtle"
|
||||
@@ -136,11 +132,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add right column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Add right column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnRight}
|
||||
variant="subtle"
|
||||
@@ -151,11 +143,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Delete column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete column")}>
|
||||
<ActionIcon
|
||||
onClick={deleteColumn}
|
||||
variant="subtle"
|
||||
@@ -168,11 +156,7 @@ export const TableMenu = React.memo(
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add row above")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Add row above")}>
|
||||
<ActionIcon
|
||||
onClick={addRowAbove}
|
||||
variant="subtle"
|
||||
@@ -183,11 +167,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add row below")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Add row below")}>
|
||||
<ActionIcon
|
||||
onClick={addRowBelow}
|
||||
variant="subtle"
|
||||
@@ -198,7 +178,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete row")} withinPortal={false}>
|
||||
<Tooltip position="top" label={t("Delete row")}>
|
||||
<ActionIcon
|
||||
onClick={deleteRow}
|
||||
variant="subtle"
|
||||
@@ -211,11 +191,7 @@ export const TableMenu = React.memo(
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Toggle header row")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Toggle header row")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="subtle"
|
||||
@@ -226,11 +202,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Toggle header column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Toggle header column")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="subtle"
|
||||
@@ -243,11 +215,7 @@ export const TableMenu = React.memo(
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Delete table")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
variant="subtle"
|
||||
@@ -260,7 +228,7 @@ export const TableMenu = React.memo(
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default TableMenu;
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.videoWrapper,
|
||||
!src && placeholder && classes.skeleton,
|
||||
!src && classes.skeleton,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
@@ -60,7 +60,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && placeholder && (
|
||||
{!src && !previewSrc && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
@@ -70,9 +70,6 @@ export default function VideoView(props: NodeViewProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && !placeholder && (
|
||||
<video className={classes.video} controls />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// https://github.com/NiclasDev63/tiptap-extension-auto-joiner - MIT
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { canJoin } from "@tiptap/pm/transform";
|
||||
import { getNodeType } from "@tiptap/react";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
import { Transaction } from "@tiptap/pm/state";
|
||||
|
||||
// https://discuss.prosemirror.net/t/how-to-autojoin-all-the-time/2957/4
|
||||
// Adapted from prosemirror-commands wrapDispatchForJoin
|
||||
function autoJoin(
|
||||
transactions: readonly Transaction[],
|
||||
newTr: Transaction,
|
||||
nodeTypes: NodeType[]
|
||||
) {
|
||||
// Collect changed ranges across all transactions, mapping earlier ranges
|
||||
// forward through later mappings so every position lands in newTr.doc space.
|
||||
let ranges: number[] = [];
|
||||
for (const tr of transactions) {
|
||||
for (let i = 0; i < tr.mapping.maps.length; i++) {
|
||||
let map = tr.mapping.maps[i];
|
||||
if (!map) continue;
|
||||
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]!);
|
||||
map.forEach((_s, _e, from, to) => ranges.push(from, to));
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out which joinable points exist inside those ranges,
|
||||
// by checking all node boundaries in their parent nodes.
|
||||
// Resolve against newTr.doc — the same document we will join on.
|
||||
let joinable: number[] = [];
|
||||
for (let i = 0; i < ranges.length; i += 2) {
|
||||
let from = ranges[i]!,
|
||||
to = ranges[i + 1]!;
|
||||
let $from = newTr.doc.resolve(from),
|
||||
depth = $from.sharedDepth(to),
|
||||
parent = $from.node(depth);
|
||||
for (
|
||||
let index = $from.indexAfter(depth), pos = $from.after(depth + 1);
|
||||
pos <= to;
|
||||
++index
|
||||
) {
|
||||
let after = parent.maybeChild(index);
|
||||
if (!after) break;
|
||||
if (index && joinable.indexOf(pos) == -1) {
|
||||
let before = parent.child(index - 1);
|
||||
if (before.type == after.type && nodeTypes.includes(before.type))
|
||||
joinable.push(pos);
|
||||
}
|
||||
pos += after.nodeSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Join the joinable points (reverse order to preserve earlier positions)
|
||||
let joined = false;
|
||||
joinable.sort((a, b) => a - b);
|
||||
for (let i = joinable.length - 1; i >= 0; i--) {
|
||||
if (canJoin(newTr.doc, joinable[i]!)) {
|
||||
newTr.join(joinable[i]!);
|
||||
joined = true;
|
||||
}
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
export interface AutoJoinerOptions {
|
||||
elementsToJoin: string[];
|
||||
}
|
||||
|
||||
const AutoJoiner = Extension.create<AutoJoinerOptions>({
|
||||
name: "autoJoiner",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
elementsToJoin: [],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugin = new PluginKey(this.name);
|
||||
const joinableNodes = [
|
||||
this.editor.schema.nodes.bulletList,
|
||||
this.editor.schema.nodes.orderedList,
|
||||
];
|
||||
this.options.elementsToJoin.forEach((element) => {
|
||||
const nodeTyp = getNodeType(element, this.editor.schema);
|
||||
joinableNodes.push(nodeTyp);
|
||||
});
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: plugin,
|
||||
appendTransaction(transactions, _, newState) {
|
||||
let newTr = newState.tr;
|
||||
if (autoJoin(transactions, newTr, joinableNodes as NodeType[])) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default AutoJoiner;
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
SharedStorage,
|
||||
Columns,
|
||||
Column,
|
||||
Status,
|
||||
Status
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -97,7 +97,6 @@ import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -142,25 +141,6 @@ export const mainExtensions = [
|
||||
}),
|
||||
];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) return false;
|
||||
if (!editor.isActive("code")) return false;
|
||||
|
||||
const $from = editor.state.doc.resolve(from);
|
||||
const codeType = editor.state.schema.marks.code;
|
||||
const nodeAfter = $from.nodeAfter;
|
||||
|
||||
if (nodeAfter && codeType.isInSet(nodeAfter.marks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.chain().unsetCode().splitBlock().run();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
SharedStorage,
|
||||
Heading,
|
||||
@@ -272,8 +252,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createImageHandle,
|
||||
@@ -285,8 +265,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
@@ -316,8 +296,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
@@ -329,8 +309,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
@@ -373,9 +353,6 @@ export const mainExtensions = [
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
AutoJoiner.configure({
|
||||
elementsToJoin: [],
|
||||
}),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { DOMParser } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
@@ -19,27 +19,6 @@ export const MarkdownClipboard = Extension.create({
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||
let topLevelCount = 0;
|
||||
let hasList = false;
|
||||
slice.content.forEach((node) => {
|
||||
if (listTypes.includes(node.type.name)) {
|
||||
hasList = true;
|
||||
topLevelCount += node.childCount;
|
||||
} else {
|
||||
topLevelCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasList || topLevelCount < 2) return null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||
const fragment = serializer.serializeFragment(slice.content);
|
||||
div.appendChild(fragment);
|
||||
return htmlToMarkdown(div.innerHTML);
|
||||
},
|
||||
handlePaste: (view, event, slice) => {
|
||||
if (!event.clipboardData) {
|
||||
return false;
|
||||
@@ -50,80 +29,49 @@ export const MarkdownClipboard = Extension.create({
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||
const language = vscodeData?.mode;
|
||||
|
||||
const isVscodeMarkdown = language === "markdown";
|
||||
const isPlainTextOnly = !html && !vscode && !!text;
|
||||
|
||||
if (!isVscodeMarkdown && !isPlainTextOnly) {
|
||||
if (language !== "markdown") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPlainTextOnly) {
|
||||
if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = find(text, {
|
||||
defaultProtocol: "http",
|
||||
}).find((item) => item.isLink && item.value === text);
|
||||
|
||||
if (link) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const { tr } = view.state;
|
||||
const { from, to } = view.state.selection;
|
||||
|
||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
||||
const html = markdownToHtml(text);
|
||||
|
||||
const contentNodes = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(elementFromString(parsed), {
|
||||
).parseSlice(elementFromString(html), {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
|
||||
tr.replaceRange(from, to, contentNodes);
|
||||
const insertEnd = tr.mapping.map(from, 1);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
||||
tr.setMeta('paste', true)
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
// Strip trailing whitespace-only paragraphs from pasted content.
|
||||
// Terminals (GNOME Terminal, etc.) often include trailing
|
||||
// whitespace in their HTML clipboard data, which ProseMirror
|
||||
// parses as an extra paragraph. Inside a list item this creates
|
||||
// an orphan empty line that breaks the list structure.
|
||||
transformPasted: (slice) => {
|
||||
let { content, openStart, openEnd } = slice;
|
||||
clipboardTextParser: (text, context, plainText) => {
|
||||
const link = find(text, {
|
||||
defaultProtocol: "http",
|
||||
}).find((item) => item.isLink && item.value === text);
|
||||
|
||||
// Remove trailing paragraphs that contain only whitespace
|
||||
while (content.childCount > 1) {
|
||||
const lastChild = content.lastChild;
|
||||
if (
|
||||
lastChild?.type.name === "paragraph" &&
|
||||
lastChild.textContent.trim() === ""
|
||||
) {
|
||||
const children = [];
|
||||
for (let i = 0; i < content.childCount - 1; i++) {
|
||||
children.push(content.child(i));
|
||||
}
|
||||
content = Fragment.from(children);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (plainText || !this.options.transformPastedText || link) {
|
||||
// don't parse plaintext link to allow link paste handler to work
|
||||
// pasting with shift key prevents formatting
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content !== slice.content) {
|
||||
return new Slice(content, openStart, Math.max(openEnd, 1));
|
||||
}
|
||||
|
||||
return slice;
|
||||
const parsed = markdownToHtml(text);
|
||||
return DOMParser.fromSchema(this.editor.schema).parseSlice(
|
||||
elementFromString(parsed),
|
||||
{
|
||||
preserveWhitespace: true,
|
||||
context,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
export function FullEditor({
|
||||
@@ -26,7 +25,6 @@ export function FullEditor({
|
||||
content,
|
||||
spaceSlug,
|
||||
editable,
|
||||
canComment,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
@@ -48,7 +46,6 @@ export function FullEditor({
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -37,11 +37,9 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
showReadOnlyCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
@@ -76,14 +74,12 @@ interface PageEditorProps {
|
||||
pageId: string;
|
||||
editable: boolean;
|
||||
content: any;
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
export default function PageEditor({
|
||||
pageId,
|
||||
editable,
|
||||
content,
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
@@ -98,7 +94,6 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@@ -428,13 +423,7 @@ export default function PageEditor({
|
||||
<ColumnsMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import ChatInput from "@/ee/ai-chat/components/chat-input";
|
||||
import type {
|
||||
ChatAttachment,
|
||||
PageMention,
|
||||
} from "@/ee/ai-chat/types/ai-chat.types";
|
||||
import classes from "./home-ai-prompt.module.css";
|
||||
|
||||
export type HomeAiPromptInitialState = {
|
||||
initialContent: string;
|
||||
initialMentions: PageMention[];
|
||||
initialAttachments: ChatAttachment[];
|
||||
};
|
||||
|
||||
export default function HomeAiPrompt() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
if (!aiChatEnabled) return null;
|
||||
|
||||
const handleSend = (
|
||||
content: string,
|
||||
mentions: PageMention[],
|
||||
attachments: ChatAttachment[],
|
||||
) => {
|
||||
if (!content.trim() && attachments.length === 0) return;
|
||||
const state: HomeAiPromptInitialState = {
|
||||
initialContent: content,
|
||||
initialMentions: mentions,
|
||||
initialAttachments: attachments,
|
||||
};
|
||||
navigate("/ai", { state });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<h1 className={classes.heading}>
|
||||
{t("Welcome to {{name}}", { name: workspace?.name ?? "Docmost" })}
|
||||
</h1>
|
||||
<div className={classes.subtitle}>
|
||||
{t("Ask anything or search your workspace")}
|
||||
</div>
|
||||
|
||||
<div className={classes.inputContainer}>
|
||||
<ChatInput
|
||||
isStreaming={false}
|
||||
onSend={handleSend}
|
||||
onStop={() => {}}
|
||||
placeholder={t("Ask anything... Use @ to mention pages")}
|
||||
autofocus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { INotification } from "../types/notification.types";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMarkReadMutation } from "../queries/notification-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
@@ -30,6 +30,7 @@ export function NotificationItem({
|
||||
onNavigate,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const markRead = useMarkReadMutation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
@@ -49,47 +50,37 @@ export function NotificationItem({
|
||||
return notification.data?.role === "writer"
|
||||
? "<bold>{{name}}</bold> gave you edit access to a page"
|
||||
: "<bold>{{name}}</bold> gave you view access to a page";
|
||||
case "page.updated":
|
||||
return "<bold>{{name}}</bold> updated a page";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const pageUrl =
|
||||
notification.page && notification.space
|
||||
? buildPageUrl(
|
||||
const handleClick = () => {
|
||||
if (notification.page && notification.space) {
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
}
|
||||
navigate(
|
||||
buildPageUrl(
|
||||
notification.space.slug,
|
||||
notification.page.slugId,
|
||||
notification.page.title,
|
||||
)
|
||||
: undefined;
|
||||
),
|
||||
);
|
||||
onNavigate();
|
||||
}
|
||||
};
|
||||
|
||||
const markReadIfNeeded = () => {
|
||||
const handleMarkRead = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
markReadIfNeeded();
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
const handleMarkRead = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
markReadIfNeeded();
|
||||
};
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={pageUrl ?? ""}
|
||||
onClick={handleClick}
|
||||
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
|
||||
// so that right-click (button 2, context menu) does not mark as read
|
||||
onAuxClick={(e: React.MouseEvent) => e.button === 1 && markReadIfNeeded()}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
w="100%"
|
||||
|
||||
@@ -3,23 +3,17 @@ import { IconBellOff } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import {
|
||||
INotification,
|
||||
NotificationFilter,
|
||||
NotificationTab,
|
||||
} from "../types/notification.types";
|
||||
import { INotification, NotificationFilter } from "../types/notification.types";
|
||||
import { groupNotificationsByTime } from "../notification.utils";
|
||||
import { useNotificationsQuery } from "../queries/notification-query";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
type NotificationListProps = {
|
||||
tab: NotificationTab;
|
||||
filter: NotificationFilter;
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
export function NotificationList({
|
||||
tab,
|
||||
filter,
|
||||
onNavigate,
|
||||
}: NotificationListProps) {
|
||||
@@ -30,7 +24,7 @@ export function NotificationList({
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useNotificationsQuery(tab as string);
|
||||
} = useNotificationsQuery();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Menu,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -19,20 +18,15 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotificationList } from "./notification-list";
|
||||
import {
|
||||
NotificationFilter,
|
||||
NotificationTab,
|
||||
} from "../types/notification.types";
|
||||
import { NotificationFilter } from "../types/notification.types";
|
||||
import {
|
||||
useMarkAllReadMutation,
|
||||
useUnreadCountQuery,
|
||||
} from "../queries/notification-query";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
export function NotificationPopover() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [tab, setTab] = useState<NotificationTab>("direct");
|
||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||
|
||||
const { data: unreadData } = useUnreadCountQuery();
|
||||
@@ -131,27 +125,13 @@ export function NotificationPopover() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => setTab(value as NotificationTab)}
|
||||
variant="default"
|
||||
color="dark"
|
||||
>
|
||||
<Tabs.List px="md">
|
||||
<Tabs.Tab value="direct">{t("Direct")}</Tabs.Tab>
|
||||
<Tabs.Tab value="updates">{t("Updates")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<ScrollArea.Autosize
|
||||
mah={500}
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
scrollbarSize={6}
|
||||
style={{ overscrollBehavior: "contain" }}
|
||||
>
|
||||
<NotificationList
|
||||
tab={tab}
|
||||
filter={filter}
|
||||
onNavigate={() => setOpened(false)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
.notificationItem {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.notificationItem:hover {
|
||||
@@ -13,4 +11,3 @@
|
||||
.divider {
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
export const NOTIFICATION_KEY = ["notifications"];
|
||||
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
|
||||
|
||||
export function useNotificationsQuery(type?: string) {
|
||||
export function useNotificationsQuery() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...NOTIFICATION_KEY, type],
|
||||
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam, type }),
|
||||
queryKey: NOTIFICATION_KEY,
|
||||
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IPagination } from "@/lib/types";
|
||||
export async function getNotifications(params: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
type?: string;
|
||||
}): Promise<IPagination<INotification>> {
|
||||
const req = await api.post<IPagination<INotification>>(
|
||||
"/notifications",
|
||||
|
||||
@@ -3,8 +3,7 @@ export type NotificationType =
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention"
|
||||
| "page.permission_granted"
|
||||
| "page.updated";
|
||||
| "page.permission_granted";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
@@ -39,5 +38,3 @@ export type INotification = {
|
||||
};
|
||||
|
||||
export type NotificationFilter = "all" | "unread";
|
||||
|
||||
export type NotificationTab = "direct" | "updates" | "all";
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
@@ -42,11 +40,6 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
useWatchStatusQuery,
|
||||
useWatchPageMutation,
|
||||
useUnwatchPageMutation,
|
||||
} from "@/features/page/queries/watcher-query";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@@ -130,9 +123,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||
const watchPage = useWatchPageMutation();
|
||||
const unwatchPage = useUnwatchPageMutation();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -195,23 +185,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
|
||||
{watchStatus?.watching ? (
|
||||
<Menu.Item
|
||||
leftSection={<IconEyeOff size={16} />}
|
||||
onClick={() => unwatchPage.mutate(page.id)}
|
||||
>
|
||||
{t("Stop watching")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<IconEye size={16} />}
|
||||
onClick={() => watchPage.mutate(page.id)}
|
||||
>
|
||||
{t("Watch page")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
watchPage,
|
||||
unwatchPage,
|
||||
getWatchStatus,
|
||||
} from "@/features/page/services/watcher-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const WATCHER_KEY = "watcher";
|
||||
|
||||
export function useWatchStatusQuery(pageId: string) {
|
||||
return useQuery({
|
||||
queryKey: [WATCHER_KEY, pageId],
|
||||
queryFn: () => getWatchStatus(pageId),
|
||||
enabled: !!pageId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWatchPageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => watchPage(pageId),
|
||||
onSuccess: (_data, pageId) => {
|
||||
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: true });
|
||||
notifications.show({ message: t("You are now watching this page") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnwatchPageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => unwatchPage(pageId),
|
||||
onSuccess: (_data, pageId) => {
|
||||
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: false });
|
||||
notifications.show({ message: t("You are no longer watching this page") });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
export async function watchPage(pageId: string): Promise<{ watching: boolean }> {
|
||||
const req = await api.post<{ watching: boolean }>("/pages/watch", { pageId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function unwatchPage(pageId: string): Promise<{ watching: boolean }> {
|
||||
const req = await api.post<{ watching: boolean }>("/pages/unwatch", { pageId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getWatchStatus(pageId: string): Promise<{ watching: boolean }> {
|
||||
const req = await api.post<{ watching: boolean }>("/pages/watch-status", { pageId });
|
||||
return req.data;
|
||||
}
|
||||
@@ -29,8 +29,6 @@
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
border: 1px solid;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin light {
|
||||
color: var(--mantine-color-gray-7);
|
||||
|
||||
@@ -3,7 +3,6 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||
import React from "react";
|
||||
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
||||
import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
@@ -60,14 +59,6 @@ export default function SpaceSettingsModal({
|
||||
<Tabs.Tab fw={500} value="members">
|
||||
{t("Members")}
|
||||
</Tabs.Tab>
|
||||
{spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
) && (
|
||||
<Tabs.Tab fw={500} value="security">
|
||||
{t("Security")}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
@@ -100,20 +91,6 @@ export default function SpaceSettingsModal({
|
||||
)}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="security">
|
||||
<ScrollArea h={580} scrollbarSize={5} pr={8}>
|
||||
<div style={{ paddingBottom: "100px" }}>
|
||||
<SpaceSecuritySettings
|
||||
space={space}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHome,
|
||||
IconPlus,
|
||||
@@ -18,11 +16,6 @@ import {
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
useSpaceWatchStatusQuery,
|
||||
useWatchSpaceMutation,
|
||||
useUnwatchSpaceMutation,
|
||||
} from "@/features/space/queries/space-watcher-query.ts";
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -167,20 +160,13 @@ export function SpaceSidebar() {
|
||||
{t("Pages")}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs">
|
||||
<SpaceMenu
|
||||
spaceId={space.id}
|
||||
canManagePages={spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
onSpaceSettings={openSettings}
|
||||
/>
|
||||
{spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Group gap="xs">
|
||||
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
||||
|
||||
{spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label={t("Create page")} withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -191,8 +177,8 @@ export function SpaceSidebar() {
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div className={classes.pages}>
|
||||
@@ -218,14 +204,9 @@ export function SpaceSidebar() {
|
||||
|
||||
interface SpaceMenuProps {
|
||||
spaceId: string;
|
||||
canManagePages: boolean;
|
||||
onSpaceSettings: () => void;
|
||||
}
|
||||
function SpaceMenu({
|
||||
spaceId,
|
||||
canManagePages,
|
||||
onSpaceSettings,
|
||||
}: SpaceMenuProps) {
|
||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
@@ -233,24 +214,15 @@ function SpaceMenu({
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
|
||||
const watchMutation = useWatchSpaceMutation();
|
||||
const unwatchMutation = useUnwatchSpaceMutation();
|
||||
const isWatching = watchStatus?.watching ?? false;
|
||||
|
||||
const handleToggleWatch = () => {
|
||||
if (isWatching) {
|
||||
unwatchMutation.mutate(spaceId);
|
||||
} else {
|
||||
watchMutation.mutate(spaceId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu width={200} shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Space menu")} withArrow position="top">
|
||||
<Tooltip
|
||||
label={t("Import pages & space settings")}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
@@ -263,69 +235,50 @@ function SpaceMenu({
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleToggleWatch}
|
||||
leftSection={
|
||||
isWatching ? <IconEyeOff size={16} /> : <IconEye size={16} />
|
||||
}
|
||||
onClick={openImportModal}
|
||||
leftSection={<IconArrowDown size={16} />}
|
||||
>
|
||||
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||
{t("Import pages")}
|
||||
</Menu.Item>
|
||||
|
||||
{canManagePages && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={openExportModal}
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
>
|
||||
{t("Export space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={openImportModal}
|
||||
leftSection={<IconArrowDown size={16} />}
|
||||
>
|
||||
{t("Import pages")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={openExportModal}
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
>
|
||||
{t("Export space")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={onSpaceSettings}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={onSpaceSettings}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/s/${spaceSlug}/trash`}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
{t("Trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/s/${spaceSlug}/trash`}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
{t("Trash")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{canManagePages && (
|
||||
<>
|
||||
<PageImportModal
|
||||
spaceId={spaceId}
|
||||
open={importOpened}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
<PageImportModal
|
||||
spaceId={spaceId}
|
||||
open={importOpened}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
type="space"
|
||||
id={spaceId}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ExportModal
|
||||
type="space"
|
||||
id={spaceId}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
.card {
|
||||
background-color: var(--mantine-color-body);
|
||||
width: 220px;
|
||||
|
||||
@mixin hover {
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.cardSection {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family:
|
||||
Greycliff CF,
|
||||
var(--mantine-font-family);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Text, Card, rem, Group, Button } from "@mantine/core";
|
||||
import {
|
||||
prefetchSpace,
|
||||
useGetSpacesQuery,
|
||||
} from "@/features/space/queries/space-query.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
import classes from "./space-carousel.module.css";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import CardCarousel from "@/components/ui/card-carousel";
|
||||
|
||||
export default function SpaceCarousel() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useGetSpacesQuery({ limit: 20 });
|
||||
|
||||
const cards = data?.items.map((space) => (
|
||||
<Card
|
||||
key={space.id}
|
||||
p="xs"
|
||||
radius="md"
|
||||
component={Link}
|
||||
to={getSpaceUrl(space.slug)}
|
||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
||||
className={classes.card}
|
||||
withBorder
|
||||
>
|
||||
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size="md"
|
||||
mt={rem(-20)}
|
||||
/>
|
||||
|
||||
<Text fz="md" fw={500} mt="xs" className={classes.title}>
|
||||
{space.name}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs" fw={700} mt="md">
|
||||
{formatMemberCount(space.memberCount, t)}
|
||||
</Text>
|
||||
</Card>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("Spaces you belong to")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<CardCarousel ariaLabel={t("Spaces you belong to")}>{cards}</CardCarousel>
|
||||
|
||||
{data?.items && data.items.length > 1 && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
component={Link}
|
||||
to="/spaces"
|
||||
variant="subtle"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
{t("View all spaces")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ResponsiveSettingsControl,
|
||||
ResponsiveSettingsRow,
|
||||
} from "@/components/ui/responsive-settings-row.tsx";
|
||||
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@@ -27,6 +27,7 @@ interface SpaceDetailsProps {
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||
const showSharingToggle = !readOnly;
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||
@@ -88,6 +89,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||
|
||||
{showSharingToggle && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<SpacePublicSharingToggle space={space} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Text, Divider } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
|
||||
|
||||
type SpaceSecuritySettingsProps = {
|
||||
space: ISpace;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function SpaceSecuritySettings({
|
||||
space,
|
||||
readOnly,
|
||||
}: SpaceSecuritySettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (readOnly) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text my="md" fw={600}>
|
||||
{t("Security")}
|
||||
</Text>
|
||||
|
||||
<SpacePublicSharingToggle space={space} />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SpaceViewerCommentsToggle space={space} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
watchSpace,
|
||||
unwatchSpace,
|
||||
getSpaceWatchStatus,
|
||||
} from "@/features/space/services/space-watcher-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SPACE_WATCHER_KEY = "space-watcher";
|
||||
|
||||
export function useSpaceWatchStatusQuery(spaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: [SPACE_WATCHER_KEY, spaceId],
|
||||
queryFn: () => getSpaceWatchStatus(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWatchSpaceMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (spaceId: string) => watchSpace(spaceId),
|
||||
onSuccess: (_data, spaceId) => {
|
||||
queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
|
||||
watching: true,
|
||||
});
|
||||
notifications.show({ message: t("You are now watching this space") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnwatchSpaceMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (spaceId: string) => unwatchSpace(spaceId),
|
||||
onSuccess: (_data, spaceId) => {
|
||||
queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
|
||||
watching: false,
|
||||
});
|
||||
notifications.show({
|
||||
message: t("You are no longer watching this space"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
export async function watchSpace(
|
||||
spaceId: string,
|
||||
): Promise<{ watching: boolean }> {
|
||||
const req = await api.post<{ watching: boolean }>("/spaces/watch", {
|
||||
spaceId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function unwatchSpace(
|
||||
spaceId: string,
|
||||
): Promise<{ watching: boolean }> {
|
||||
const req = await api.post<{ watching: boolean }>("/spaces/unwatch", {
|
||||
spaceId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSpaceWatchStatus(
|
||||
spaceId: string,
|
||||
): Promise<{ watching: boolean }> {
|
||||
const req = await api.post<{ watching: boolean }>("/spaces/watch-status", {
|
||||
spaceId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
@@ -9,13 +9,8 @@ export interface ISpaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
comments?: ISpaceCommentsSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
@@ -34,7 +29,6 @@ export interface ISpace {
|
||||
settings?: ISpaceSettings;
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import { IUser, IUserSettings } from "@/features/user/types/user.types.ts";
|
||||
import { Switch, Text, Title, Stack } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ResponsiveSettingsRow,
|
||||
ResponsiveSettingsContent,
|
||||
ResponsiveSettingsControl,
|
||||
} from "@/components/ui/responsive-settings-row";
|
||||
|
||||
type NotificationKey = keyof NonNullable<IUserSettings["notifications"]>;
|
||||
|
||||
const notificationItems: {
|
||||
key: NotificationKey;
|
||||
dtoField: keyof IUser;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "page.updated",
|
||||
dtoField: "notificationPageUpdates",
|
||||
label: "Page updates",
|
||||
description: "Get notified when pages you watch are updated.",
|
||||
},
|
||||
{
|
||||
key: "page.userMention",
|
||||
dtoField: "notificationPageUserMention",
|
||||
label: "Page mentions",
|
||||
description: "Get notified when someone mentions you on a page.",
|
||||
},
|
||||
{
|
||||
key: "comment.userMention",
|
||||
dtoField: "notificationCommentUserMention",
|
||||
label: "Comment mentions",
|
||||
description: "Get notified when someone mentions you in a comment.",
|
||||
},
|
||||
{
|
||||
key: "comment.created",
|
||||
dtoField: "notificationCommentCreated",
|
||||
label: "New comments",
|
||||
description:
|
||||
"Get notified about new comments on threads you participate in.",
|
||||
},
|
||||
{
|
||||
key: "comment.resolved",
|
||||
dtoField: "notificationCommentResolved",
|
||||
label: "Resolved comments",
|
||||
description: "Get notified when your comment is resolved.",
|
||||
},
|
||||
];
|
||||
|
||||
function NotificationToggle({
|
||||
settingKey,
|
||||
dtoField,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
settingKey: NotificationKey;
|
||||
dtoField: keyof IUser;
|
||||
label: string;
|
||||
description: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const [checked, setChecked] = useState(
|
||||
user.settings?.notifications?.[settingKey] !== false,
|
||||
);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
setChecked(value);
|
||||
try {
|
||||
const updatedUser = await updateUser({ [dtoField]: value } as any);
|
||||
setUser(updatedUser);
|
||||
} catch {
|
||||
setChecked(!value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Text size="md">{t(label)}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(description)}
|
||||
</Text>
|
||||
</ResponsiveSettingsContent>
|
||||
|
||||
<ResponsiveSettingsControl>
|
||||
<Switch checked={checked} onChange={handleChange} />
|
||||
</ResponsiveSettingsControl>
|
||||
</ResponsiveSettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationPref() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Title order={5}>{t("Email notifications")}</Title>
|
||||
|
||||
{notificationItems.map((item) => (
|
||||
<NotificationToggle
|
||||
key={item.key}
|
||||
settingKey={item.key}
|
||||
dtoField={item.dtoField}
|
||||
label={item.label}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,6 @@ export interface IUser {
|
||||
deletedAt: Date;
|
||||
fullPageWidth: boolean; // used for update
|
||||
pageEditMode: string; // used for update
|
||||
notificationPageUpdates: boolean; // used for update
|
||||
notificationPageUserMention: boolean; // used for update
|
||||
notificationCommentUserMention: boolean; // used for update
|
||||
notificationCommentCreated: boolean; // used for update
|
||||
notificationCommentResolved: boolean; // used for update
|
||||
hasGeneratedPassword?: boolean;
|
||||
}
|
||||
|
||||
@@ -38,13 +33,6 @@ export interface IUserSettings {
|
||||
fullPageWidth: boolean;
|
||||
pageEditMode: string;
|
||||
};
|
||||
notifications?: {
|
||||
"page.updated"?: boolean;
|
||||
"page.userMention"?: boolean;
|
||||
"comment.userMention"?: boolean;
|
||||
"comment.created"?: boolean;
|
||||
"comment.resolved"?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export enum PageEditMode {
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
generative?: boolean;
|
||||
mcp?: boolean;
|
||||
chat?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Container, Space } from "@mantine/core";
|
||||
import HomeTabs from "@/features/home/components/home-tabs";
|
||||
import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
|
||||
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
|
||||
import SpaceGrid from "@/features/space/components/space-grid.tsx";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,11 +16,7 @@ export default function Home() {
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size={"800"} pt="xl">
|
||||
<HomeAiPrompt />
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
<SpaceCarousel />
|
||||
<SpaceGrid />
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
|
||||
@@ -53,9 +53,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
const canComment =
|
||||
canEdit ||
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
@@ -107,7 +104,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
slugId={page.slugId}
|
||||
spaceSlug={page?.space?.slug}
|
||||
editable={canEdit}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
|
||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
||||
import NotificationPref from "@/features/user/components/notification-pref";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Divider } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -34,10 +33,6 @@ export default function AccountPreferences() {
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageEditPref />
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<NotificationPref />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.71.1",
|
||||
"version": "0.70.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -44,23 +44,21 @@
|
||||
"@langchain/core": "1.1.34",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/platform-fastify": "^11.1.17",
|
||||
"@nestjs/platform-socket.io": "^11.1.17",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@nestjs/websockets": "^11.1.17",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "1.0.10",
|
||||
"@react-email/render": "2.0.4",
|
||||
@@ -75,12 +73,9 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie": "^1.1.1",
|
||||
"fast-bm25": "0.0.5",
|
||||
"fastify-ip": "^2.0.0",
|
||||
"fs-extra": "^11.3.4",
|
||||
"happy-dom": "20.8.9",
|
||||
"happy-dom": "20.8.4",
|
||||
"ioredis": "^5.10.1",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"kysely": "^0.28.14",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
@@ -94,7 +89,7 @@
|
||||
"nestjs-cls": "^6.2.0",
|
||||
"nestjs-kysely": "^3.1.2",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^8.0.3",
|
||||
"openid-client": "^6.8.2",
|
||||
"otpauth": "^9.5.0",
|
||||
"p-limit": "^7.3.0",
|
||||
@@ -123,9 +118,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@nestjs/cli": "^11.0.18",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.17",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -148,7 +143,7 @@
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -26,7 +26,6 @@ import KeyvRedis from '@keyv/redis';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -84,7 +83,6 @@ try {
|
||||
EventEmitterModule.forRoot(),
|
||||
SecurityModule,
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
@@ -28,53 +27,6 @@ export class CollaborationHandler {
|
||||
// const fragment = doc.getXmlFragment('default');
|
||||
//});
|
||||
},
|
||||
setCommentMark: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
yjsSelection: YjsSelection;
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { yjsSelection, commentId, resolved, user } = payload;
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
setYjsMark(doc, fragment, yjsSelection, 'comment', {
|
||||
commentId,
|
||||
resolved,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
resolveCommentMark: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { commentId, resolved, user } = payload;
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
updateYjsMarkAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
{ name: 'commentId', value: commentId },
|
||||
{ resolved },
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
updatePageContent: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
@@ -106,7 +58,8 @@ export class CollaborationHandler {
|
||||
} else {
|
||||
const newContent = prosemirrorJson.content || [];
|
||||
const yElements = newContent.map(prosemirrorNodeToYElement);
|
||||
const position = operation === 'prepend' ? 0 : fragment.length;
|
||||
const position =
|
||||
operation === 'prepend' ? 0 : fragment.length;
|
||||
fragment.insert(position, yElements);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,10 +18,12 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import {
|
||||
extractMentions,
|
||||
extractPageMentions,
|
||||
extractUserMentions,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import {
|
||||
IPageBacklinkJob,
|
||||
IPageHistoryJob,
|
||||
IPageMentionNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
@@ -41,6 +43,7 @@ export class PersistenceExtension implements Extension {
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
@@ -162,6 +165,13 @@ export class PersistenceExtension implements Extension {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
const pageMentions = extractPageMentions(mentions);
|
||||
|
||||
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
|
||||
pageId: pageId,
|
||||
workspaceId: page.workspaceId,
|
||||
mentions: pageMentions,
|
||||
} as IPageBacklinkJob);
|
||||
|
||||
const userMentions = extractUserMentions(mentions);
|
||||
const oldMentions = page.content ? extractMentions(page.content) : [];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user