Compare commits

..

33 Commits

Author SHA1 Message Date
Philip Okugbe de60aa7e61 feat: synced blocks (transclusion) (#2163)
* feat: synced blocks (transclusion)

* fix:remove name

* make placeholders smaller

* feat: enforce strict transclusion schema

* fix: scope synced blocks to workspace, gate unsync on edit permission

* fix collab module error
2026-05-08 13:23:16 +01:00
Peter Tripp c9fa6e20b3 Add alias: /toc and /ol (#2161) 2026-05-08 01:20:27 +01:00
Philipinho ec51ca7815 fix request ip 2026-05-07 22:09:32 +01:00
Philipinho 2b63137217 mail 2026-05-07 18:13:24 +01:00
Philipinho 3227bc6059 fix: a11y 2026-05-04 23:04:26 +01:00
Philip Okugbe 73dc62bca3 update react-email (#2149) 2026-05-04 22:26:53 +01:00
Philipinho 3c74bb3dee update package 2026-05-04 22:09:19 +01:00
Philip Okugbe dbe6c2d6ba feat: A11y fixes (#2148) 2026-05-04 21:21:37 +01:00
Sarthak Chaturvedi fe18f22dc6 fix: prevent code block deletion when adding inline comments in read mode (#2146) 2026-05-04 21:14:21 +01:00
Philipinho fcef0c6b96 fix: S3 2026-05-04 20:57:35 +01:00
Philipinho 17f3158a3b update aws packages 2026-05-01 20:00:20 +01:00
Philipinho b74ca00bfd sync 2026-05-01 14:57:32 +01:00
Philip Okugbe c247d4c1e3 feat(ee): PDF import (#2142)
* feat: replace pdfjs-dist with firecrawl-pdf-inspector

* use modified firecrawl-pdf-inspector

* feat: pdf import

* increase single file upload size limit

* use npm package

* sync

* update package
2026-05-01 14:56:39 +01:00
Philip Okugbe 641ce142df feat(ee): SCIM (#1347)
* SCIM - init (EE)

* accept db transaction

* sync

* Content parser support for scim+json

* patch scimmy

* sync

* return early if userIds is empty

* sync

* SCIM db table

* fixes

* scim tokens

* backfill

* feat(audit): add scim token events

* rename scim migration

* fix

* fix translation

* cleanup
2026-05-01 14:53:30 +01:00
Sarthak Chaturvedi 1d2486455f fix: prevent browser tab fallback in editor (#2123) 2026-05-01 13:58:51 +01:00
Philipinho a0aea43e25 feat(saml): allow disabling RequestedAuthnContext via env var
Adds SAML_DISABLE_REQUESTED_AUTHN_CONTEXT env var, passed through
    to the SAML strategy's disableRequestedAuthnContext option.
    Defaults to existing behavior (element sent). Set to true to omit
    the element when the IdP authenticates the user with a method that
    does not match (e.g. MFA, FIDO, passwordless), which would
    otherwise cause AADSTS75011 with Microsoft Entra ID.
2026-05-01 11:47:03 +01:00
Philip Okugbe 09c69d7a0f feat: properly preserve table width (#2143) 2026-05-01 00:49:31 +01:00
Sarthak Chaturvedi 9943e104a5 fix(i18n): Correct German column count label rendering (#2131) 2026-05-01 00:37:59 +01:00
Peter Tripp b16f1e5a55 fix: ctrl-k behavior on macOS (#2052)
* Improve cmd-k / ctrl-k behavior

Use cmd-k on macOS/iOS for search and keep ctrl-k everywhere else.

Fixes a bug where ctrl-k on macOS, which cuts to the end of the line,
was also triggering the search prompt.

* comment submit: cmd-enter (mac) / ctrl-enter (win/linux)
2026-05-01 00:36:40 +01:00
Philip Okugbe 24be90b95f fix: duplicate PDF uploads (#2139) 2026-04-29 10:01:47 +01:00
Olivier Lambert 3ecf27c6b0 fix(page-permission): make people-with-access list scroll past 4 entries (#2137)
The "People with access" list in the page share modal used
<ScrollArea mah={250}>, which caps the container height but does not
make the inner viewport scroll (no fixed height is given to the
viewport). Items beyond ~4 entries were rendered correctly but clipped
out of view.

Switches to <ScrollArea.Autosize mah={400}>, which is Mantine's
dedicated primitive for "grow with content up to a max, then scroll".

Closes #2135
2026-04-29 09:36:38 +01:00
Philipinho 980521f957 v0.80.1 2026-04-27 16:06:32 +01:00
Philipinho fe44dc92a9 sync 2026-04-27 15:51:23 +01:00
Philip Okugbe fad410ef23 chore: add undici for oidc proxy support (#2132) 2026-04-27 15:50:42 +01:00
Philipinho 15b8908b1a update postcss 2026-04-27 15:23:47 +01:00
Philipinho 8e15b22d8c package updates 2026-04-27 15:22:02 +01:00
Philipinho ec83fc82d5 fix: refactor sanitize 2026-04-27 15:16:26 +01:00
Philipinho a573acedd0 fix: local storage, and package overrides 2026-04-22 14:13:25 +01:00
Philipinho dba8e315ab override 2026-04-14 17:59:59 +01:00
Philipinho 81ae7a17a6 confirm dialog 2026-04-14 17:56:36 +01:00
Philipinho 271f855761 v0.80.0 2026-04-14 17:08:44 +01:00
Philipinho 3e6d915227 sync 2026-04-14 16:34:44 +01:00
Philip Okugbe a6a7e4370a feat(ee): PDF export api (#2112)
* feat(ee): server side PDF export

* feat: pdf export queue

* sync

* sync
2026-04-14 16:26:54 +01:00
216 changed files with 7975 additions and 2179 deletions
+6 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.71.1", "version": "0.80.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -31,8 +31,8 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "^25.10.1", "i18next": "25.10.1",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "3.0.6",
"jotai": "^2.18.1", "jotai": "^2.18.1",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -42,7 +42,7 @@
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0", "mermaid": "^11.13.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.363.1", "posthog-js": "1.372.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
@@ -50,11 +50,10 @@
"react-drawio": "^1.0.7", "react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1", "react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0", "react-helmet-async": "^3.0.0",
"react-i18next": "^16.5.8", "react-i18next": "16.5.8",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"semver": "^7.7.4", "semver": "^7.7.4",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -74,7 +73,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0", "globals": "^15.13.0",
"optics-ts": "^2.4.1", "optics-ts": "^2.4.1",
"postcss": "^8.5.8", "postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -222,6 +222,8 @@
"Edit comment": "Kommentar bearbeiten", "Edit comment": "Kommentar bearbeiten",
"Delete comment": "Kommentar löschen", "Delete comment": "Kommentar löschen",
"Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?", "Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?",
"Delete chat": "Chat löschen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"Comment created successfully": "Kommentar erfolgreich erstellt", "Comment created successfully": "Kommentar erfolgreich erstellt",
"Error creating comment": "Fehler beim Erstellen des Kommentars", "Error creating comment": "Fehler beim Erstellen des Kommentars",
"Comment updated successfully": "Kommentar erfolgreich aktualisiert", "Comment updated successfully": "Kommentar erfolgreich aktualisiert",
@@ -389,7 +391,7 @@
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein", "Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
"Write...": "\"Schreiben...\"", "Write...": "\"Schreiben...\"",
"Column count": "Spaltenanzahl", "Column count": "Spaltenanzahl",
"{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}", "{{count}} Columns": "{{count}} Spalten",
"Equal columns": "Gleich breite Spalten", "Equal columns": "Gleich breite Spalten",
"Left sidebar": "Linke Seitenleiste", "Left sidebar": "Linke Seitenleiste",
"Right sidebar": "Rechte Seitenleiste", "Right sidebar": "Rechte Seitenleiste",
@@ -222,6 +222,8 @@
"Edit comment": "Edit comment", "Edit comment": "Edit comment",
"Delete comment": "Delete comment", "Delete comment": "Delete comment",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?", "Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Delete chat": "Delete chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Are you sure you want to delete '{{title}}'? This action cannot be undone.",
"Comment created successfully": "Comment created successfully", "Comment created successfully": "Comment created successfully",
"Error creating comment": "Error creating comment", "Error creating comment": "Error creating comment",
"Comment updated successfully": "Comment updated successfully", "Comment updated successfully": "Comment updated successfully",
@@ -414,6 +416,7 @@
"{{latestVersion}} is available": "{{latestVersion}} is available", "{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode", "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.", "Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Choose {{format}} file": "Choose {{format}} file",
"Reading": "Reading", "Reading": "Reading",
"Delete member": "Delete member", "Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully", "Member deleted successfully": "Member deleted successfully",
@@ -606,25 +609,21 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully", "Image removed successfully": "Image removed successfully",
"API key": "API key", "API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys", "API keys": "API keys",
"API management": "API management", "API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date", "Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name", "Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration", "Expiration": "Expiration",
"Expired": "Expired", "Expired": "Expired",
"Expires": "Expires", "Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used", "Last use": "Last Used",
"No API keys found": "No API keys found", "No API keys found": "No API keys found",
"No expiration": "No expiration", "No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully", "Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date", "Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key", "Update": "Update",
"Update {{credential}}": "Update {{credential}}",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"Restrict API key creation to admins": "Restrict API key creation to admins", "Restrict API key creation to admins": "Restrict API key creation to admins",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.", "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.",
@@ -871,6 +870,12 @@
"Previous 7 days": "Previous 7 days", "Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days", "Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...", "Search chats...": "Search chats...",
"Search chats": "Search chats",
"Ask anything... Use @ to mention pages": "Ask anything... Use @ to mention pages",
"Ask anything or search your workspace": "Ask anything or search your workspace",
"Welcome to {{name}}": "Welcome to {{name}}",
"Add files": "Add files",
"Mention a page": "Mention a page",
"Start a new chat to see it here.": "Start a new chat to see it here.", "Start a new chat to see it here.": "Start a new chat to see it here.",
"Summarize this page": "Summarize this page", "Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat", "Toggle AI Chat": "Toggle AI Chat",
@@ -878,5 +883,68 @@
"Try a different search term.": "Try a different search term.", "Try a different search term.": "Try a different search term.",
"Try again": "Try again", "Try again": "Try again",
"Untitled chat": "Untitled chat", "Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?" "What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.",
"Create {{credential}}": "Create {{credential}}",
"{{credential}} created": "{{credential}} created",
"{{credential}} created successfully": "{{credential}} created successfully",
"Created by": "Created by",
"Custom": "Custom",
"Enable SCIM": "Enable SCIM",
"Enter a descriptive name": "Enter a descriptive name",
"I've saved my {{credential}}": "I've saved my {{credential}}",
"Important": "Important",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!",
"Never": "Never",
"Revoke {{credential}}": "Revoke {{credential}}",
"SCIM endpoint URL": "SCIM endpoint URL",
"SCIM provisioning": "SCIM provisioning",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
"SCIM token": "SCIM token",
"SCIM tokens": "SCIM tokens",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
"Token": "Token",
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
"Breadcrumbs": "Breadcrumbs",
"Page actions": "Page actions",
"Pick emoji": "Pick emoji",
"Template menu": "Template menu",
"Chat menu": "Chat menu",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
"Slash commands": "Slash commands",
"Mention suggestions": "Mention suggestions",
"Link suggestions": "Link suggestions",
"Diagram editor": "Diagram editor",
"Add comment": "Add comment",
"Find and replace": "Find and replace",
"Main navigation": "Main navigation",
"Space navigation": "Space navigation",
"Settings navigation": "Settings navigation",
"AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb",
"Skip to main content": "Skip to main content"
"Synced block": "Synced block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Editing original": "Editing original",
"Copy synced block": "Copy synced block",
"Unsync": "Unsync",
"Delete synced block": "Delete synced block",
"Synced to {{count}} other page_one": "Synced to {{count}} other page",
"Synced to {{count}} other page_other": "Synced to {{count}} other pages",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "THIS PAGE",
"No pages": "No pages",
"The original synced block no longer exists": "The original synced block no longer exists",
"You don't have access to this synced block": "You don't have access to this synced block",
"Failed to load this synced block": "Failed to load this synced block"
} }
@@ -222,6 +222,8 @@
"Edit comment": "Editar comentario", "Edit comment": "Editar comentario",
"Delete comment": "Eliminar comentario", "Delete comment": "Eliminar comentario",
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?", "Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
"Delete chat": "Eliminar chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}'? Esta acción no se puede deshacer.",
"Comment created successfully": "Comentario creado con éxito", "Comment created successfully": "Comentario creado con éxito",
"Error creating comment": "Error al crear comentario", "Error creating comment": "Error al crear comentario",
"Comment updated successfully": "Comentario actualizado con éxito", "Comment updated successfully": "Comentario actualizado con éxito",
@@ -222,6 +222,8 @@
"Edit comment": "Modifier le commentaire", "Edit comment": "Modifier le commentaire",
"Delete comment": "Supprimer le commentaire", "Delete comment": "Supprimer le commentaire",
"Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?", "Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?",
"Delete chat": "Supprimer la conversation",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer '{{title}}' ? Cette action est irréversible.",
"Comment created successfully": "Commentaire créé avec succès", "Comment created successfully": "Commentaire créé avec succès",
"Error creating comment": "Erreur lors de la création du commentaire", "Error creating comment": "Erreur lors de la création du commentaire",
"Comment updated successfully": "Commentaire mis à jour avec succès", "Comment updated successfully": "Commentaire mis à jour avec succès",
@@ -222,6 +222,8 @@
"Edit comment": "Modifica commento", "Edit comment": "Modifica commento",
"Delete comment": "Elimina commento", "Delete comment": "Elimina commento",
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?", "Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
"Delete chat": "Elimina chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare '{{title}}'? Questa azione non può essere annullata.",
"Comment created successfully": "Commento creato con successo", "Comment created successfully": "Commento creato con successo",
"Error creating comment": "Si è verificato un errore durante la creazione del commento", "Error creating comment": "Si è verificato un errore durante la creazione del commento",
"Comment updated successfully": "Commento aggiornato con successo", "Comment updated successfully": "Commento aggiornato con successo",
@@ -222,6 +222,8 @@
"Edit comment": "コメントを編集する", "Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する", "Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Delete chat": "チャットを削除",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "「{{title}}」を削除してもよろしいですか?この操作は元に戻せません。",
"Comment created successfully": "コメントを作成しました", "Comment created successfully": "コメントを作成しました",
"Error creating comment": "コメントの作成に失敗しました", "Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメントを更新しました", "Comment updated successfully": "コメントを更新しました",
@@ -222,6 +222,8 @@
"Edit comment": "댓글 수정", "Edit comment": "댓글 수정",
"Delete comment": "댓글 삭제", "Delete comment": "댓글 삭제",
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?", "Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Comment created successfully": "댓글 생성 완료", "Comment created successfully": "댓글 생성 완료",
"Error creating comment": "댓글 생성 오류", "Error creating comment": "댓글 생성 오류",
"Comment updated successfully": "댓글 업데이트 완료", "Comment updated successfully": "댓글 업데이트 완료",
@@ -222,6 +222,8 @@
"Edit comment": "Bewerk reactie", "Edit comment": "Bewerk reactie",
"Delete comment": "Verwijder reactie", "Delete comment": "Verwijder reactie",
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?", "Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
"Delete chat": "Chat verwijderen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Weet je zeker dat je '{{title}}' wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"Comment created successfully": "Reactie succesvol aangemaakt", "Comment created successfully": "Reactie succesvol aangemaakt",
"Error creating comment": "Fout bij het aanmaken van reactie", "Error creating comment": "Fout bij het aanmaken van reactie",
"Comment updated successfully": "Opmerking succesvol bijgewerkt", "Comment updated successfully": "Opmerking succesvol bijgewerkt",
@@ -222,6 +222,8 @@
"Edit comment": "Editar comentário", "Edit comment": "Editar comentário",
"Delete comment": "Excluir comentário", "Delete comment": "Excluir comentário",
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?", "Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
"Delete chat": "Excluir chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir '{{title}}'? Esta ação não pode ser desfeita.",
"Comment created successfully": "Comentário criado com sucesso", "Comment created successfully": "Comentário criado com sucesso",
"Error creating comment": "Erro ao criar comentário", "Error creating comment": "Erro ao criar comentário",
"Comment updated successfully": "Comentário atualizado com sucesso", "Comment updated successfully": "Comentário atualizado com sucesso",
@@ -222,6 +222,8 @@
"Edit comment": "Редактировать комментарий", "Edit comment": "Редактировать комментарий",
"Delete comment": "Удалить комментарий", "Delete comment": "Удалить комментарий",
"Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?", "Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?",
"Delete chat": "Удалить чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите удалить '{{title}}'? Это действие нельзя отменить.",
"Comment created successfully": "Комментарий успешно создан", "Comment created successfully": "Комментарий успешно создан",
"Error creating comment": "Ошибка при создании комментария", "Error creating comment": "Ошибка при создании комментария",
"Comment updated successfully": "Комментарий успешно обновлён", "Comment updated successfully": "Комментарий успешно обновлён",
@@ -222,6 +222,8 @@
"Edit comment": "Редагувати коментар", "Edit comment": "Редагувати коментар",
"Delete comment": "Видалити коментар", "Delete comment": "Видалити коментар",
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?", "Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
"Delete chat": "Видалити чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете видалити '{{title}}'? Цю дію неможливо скасувати.",
"Comment created successfully": "Коментар успішно створено", "Comment created successfully": "Коментар успішно створено",
"Error creating comment": "Помилка при створенні коментаря", "Error creating comment": "Помилка при створенні коментаря",
"Comment updated successfully": "Коментар успішно оновлено", "Comment updated successfully": "Коментар успішно оновлено",
@@ -222,6 +222,8 @@
"Edit comment": "编辑评论", "Edit comment": "编辑评论",
"Delete comment": "删除评论", "Delete comment": "删除评论",
"Are you sure you want to delete this comment?": "你确定要删除这条评论吗?", "Are you sure you want to delete this comment?": "你确定要删除这条评论吗?",
"Delete chat": "删除聊天",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "您确定要删除「{{title}}」吗?此操作无法撤销。",
"Comment created successfully": "成功创建评论", "Comment created successfully": "成功创建评论",
"Error creating comment": "创建评论时出错", "Error creating comment": "创建评论时出错",
"Comment updated successfully": "评论更新成功", "Comment updated successfully": "评论更新成功",
@@ -80,6 +80,12 @@ export default function AvatarUploader({
} }
}; };
const ariaLabel = {
[AvatarIconType.AVATAR]: t("Change avatar"),
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
}[type];
const handleRemove = async () => { const handleRemove = async () => {
if (disabled) return; if (disabled) return;
@@ -104,6 +110,8 @@ export default function AvatarUploader({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg" accept="image/png,image/jpeg,image/jpg"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }} style={{ display: "none" }}
/> />
@@ -115,6 +123,8 @@ export default function AvatarUploader({
size={size} size={size}
avatarUrl={currentImageUrl} avatarUrl={currentImageUrl}
name={fallbackName} name={fallbackName}
aria-label={ariaLabel}
aria-haspopup="menu"
style={{ style={{
cursor: disabled || isLoading ? "default" : "pointer", cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1, opacity: isLoading ? 0.6 : 1,
@@ -25,6 +25,7 @@ export default function CopyTextButton({ text, size }: CopyProps) {
variant="subtle" variant="subtle"
onClick={copy} onClick={copy}
size={size} size={size}
aria-label={copied ? t("Copied") : t("Copy")}
> >
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />} {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon> </ActionIcon>
@@ -4,7 +4,7 @@ import {
UnstyledButton, UnstyledButton,
Badge, Badge,
Table, Table,
ActionIcon, ThemeIcon,
Button, Button,
} from "@mantine/core"; } from "@mantine/core";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -49,9 +49,9 @@ export default function RecentChanges({ spaceId }: Props) {
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}> <ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} /> <IconFileDescription size={18} />
</ActionIcon> </ThemeIcon>
)} )}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
@@ -6,12 +6,14 @@ import { useTranslation } from "react-i18next";
export interface SearchInputProps { export interface SearchInputProps {
placeholder?: string; placeholder?: string;
ariaLabel?: string;
debounceDelay?: number; debounceDelay?: number;
onSearch: (value: string) => void; onSearch: (value: string) => void;
} }
export function SearchInput({ export function SearchInput({
placeholder, placeholder,
ariaLabel,
debounceDelay = 500, debounceDelay = 500,
onSearch, onSearch,
}: SearchInputProps) { }: SearchInputProps) {
@@ -28,6 +30,7 @@ export function SearchInput({
<TextInput <TextInput
size="sm" size="sm"
placeholder={placeholder || t("Search...")} placeholder={placeholder || t("Search...")}
aria-label={ariaLabel || placeholder || t("Search")}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
value={value} value={value}
onChange={(e) => setValue(e.currentTarget.value)} onChange={(e) => setValue(e.currentTarget.value)}
@@ -1,11 +1,11 @@
import { ActionIcon, rem } from "@mantine/core"; import { ThemeIcon } from "@mantine/core";
import React from "react"; import React from "react";
import { IconUsersGroup } from "@tabler/icons-react"; import { IconUsersGroup } from "@tabler/icons-react";
export function IconGroupCircle() { export function IconGroupCircle() {
return ( return (
<ActionIcon variant="light" size="lg" color="gray" radius="xl"> <ThemeIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} /> <IconUsersGroup stroke={1.5} />
</ActionIcon> </ThemeIcon>
); );
} }
@@ -28,4 +28,22 @@
} }
} }
.skipLink {
position: fixed;
left: 8px;
top: 8px;
padding: 8px 12px;
background: var(--mantine-color-blue-6);
color: #fff;
border-radius: 4px;
text-decoration: none;
z-index: 1000;
transform: translateY(-150%);
&:focus {
transform: translateY(0);
outline: 2px solid var(--mantine-color-blue-3);
}
}
@@ -1,6 +1,7 @@
import { AppShell, Container } from "@mantine/core"; import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
@@ -23,11 +24,12 @@ export default function GlobalAppShell({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { t } = useTranslation();
useTrialEndAction(); useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom); const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom); const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null); const sidebarRef = useRef(null);
@@ -79,7 +81,11 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return ( return (
<AppShell <>
<a href="#main-content" className={classes.skipLink}>
{t("Skip to main content")}
</a>
<AppShell
header={{ height: 45 }} header={{ height: 45 }}
navbar={{ navbar={{
width: isSpaceRoute ? sidebarWidth : 300, width: isSpaceRoute ? sidebarWidth : 300,
@@ -105,6 +111,15 @@ export default function GlobalAppShell({
className={classes.navbar} className={classes.navbar}
withBorder={false} withBorder={false}
ref={sidebarRef} ref={sidebarRef}
aria-label={
isSpaceRoute
? t("Space navigation")
: isSettingsRoute
? t("Settings navigation")
: isAiRoute
? t("AI navigation")
: t("Main navigation")
}
> >
{isSpaceRoute && ( {isSpaceRoute && (
<div className={classes.resizeHandle} onMouseDown={startResizing} /> <div className={classes.resizeHandle} onMouseDown={startResizing} />
@@ -114,19 +129,35 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />} {isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main id="main-content">
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={900}>{children}</Container> <Container size={900} pb={80}>
{children}
</Container>
) : ( ) : (
children children
)} )}
</AppShell.Main> </AppShell.Main>
{isPageRoute && ( {isPageRoute && (
<AppShell.Aside className={classes.aside} p="md" withBorder={false}> <AppShell.Aside
className={classes.aside}
p="md"
withBorder={false}
aria-label={
asideTab === "comments"
? t("Comments")
: asideTab === "toc"
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: undefined
}
>
<Aside /> <Aside />
</AppShell.Aside> </AppShell.Aside>
)} )}
</AppShell> </AppShell>
</>
); );
} }
@@ -50,7 +50,7 @@
.sectionHeader { .sectionHeader {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3)); color: var(--mantine-color-dimmed);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal } from "@mantine/core"; import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
import { import {
IconHome, IconHome,
IconClock, IconClock,
@@ -119,17 +119,13 @@ export default function GlobalSidebar() {
</ScrollArea> </ScrollArea>
<div className={classes.bottomSection}> <div className={classes.bottomSection}>
<a <UnstyledButton
className={classes.link} className={classes.link}
onClick={(e) => { onClick={openInvite}
e.preventDefault();
openInvite();
}}
href="#"
> >
<IconUserPlus className={classes.linkIcon} stroke={2} /> <IconUserPlus className={classes.linkIcon} stroke={2} />
<span>{t("Invite People")}</span> <span>{t("Invite People")}</span>
</a> </UnstyledButton>
<Link <Link
className={classes.link} className={classes.link}
data-active={active.startsWith("/settings") || undefined} data-active={active.startsWith("/settings") || undefined}
@@ -29,7 +29,7 @@ export default function AppVersion() {
> >
<Indicator <Indicator
label={t("New update")} label={t("New update")}
color="gray" color="dark"
inline inline
size={16} size={16}
position="middle-end" position="middle-end"
@@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service"; import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" }; const params: QueryParams = { limit: 100, query: "" };
@@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => {
queryFn: () => getVerificationList(params), queryFn: () => getVerificationList(params),
}); });
}; };
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -31,6 +31,7 @@ import {
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
prefetchScimTokens,
prefetchShares, prefetchShares,
prefetchSpaces, prefetchSpaces,
prefetchSsoProviders, prefetchSsoProviders,
@@ -204,7 +205,10 @@ export default function SettingsSidebar() {
} }
break; break;
case "Security & SSO": case "Security & SSO":
prefetchHandler = prefetchSsoProviders; prefetchHandler = () => {
prefetchSsoProviders();
prefetchScimTokens();
};
break; break;
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
@@ -226,32 +230,6 @@ export default function SettingsSidebar() {
} }
const isDisabled = isItemDisabled(item); const isDisabled = isItemDisabled(item);
const linkElement = (
<Link
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
data-disabled={isDisabled || undefined}
key={item.label}
to={isDisabled ? "#" : item.path}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
return;
}
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
style={{
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? "not-allowed" : "pointer",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
if (isDisabled) { if (isDisabled) {
return ( return (
@@ -261,12 +239,41 @@ export default function SettingsSidebar() {
position="right" position="right"
withArrow withArrow
> >
{linkElement} <span
className={classes.link}
data-disabled
role="link"
aria-disabled="true"
tabIndex={0}
style={{
opacity: 0.5,
cursor: "not-allowed",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</span>
</Tooltip> </Tooltip>
); );
} }
return linkElement; return (
<Link
onMouseEnter={prefetchHandler}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
})} })}
</div> </div>
); );
@@ -284,7 +291,7 @@ export default function SettingsSidebar() {
}} }}
variant="transparent" variant="transparent"
c="gray" c="gray"
aria-label="Back" aria-label={t("Back")}
> >
<IconArrowLeft stroke={2} /> <IconArrowLeft stroke={2} />
</ActionIcon> </ActionIcon>
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Avatar } from "@mantine/core"; import { Avatar, MantineColor } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts"; import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
@@ -16,11 +16,39 @@ interface CustomAvatarProps {
mt?: string | number; mt?: string | number;
} }
// `color.shade` pairs whose filled background meets WCAG AA (4.5:1) against
// white text. Avoids lime/yellow/green/orange — even their dark shades have
// weak white-text contrast.
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
"grape.7",
"indigo.7",
"pink.8",
"red.8",
"violet.7",
];
function hashName(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function pickInitialsColor(name: string) {
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
}
export const CustomAvatar = React.forwardRef< export const CustomAvatar = React.forwardRef<
HTMLInputElement, HTMLInputElement,
CustomAvatarProps CustomAvatarProps
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => { >(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type); const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
return ( return (
<Avatar <Avatar
@@ -28,7 +56,7 @@ export const CustomAvatar = React.forwardRef<
src={avatarLink} src={avatarLink}
name={name} name={name}
alt={name} alt={name}
color="initials" color={resolvedColor}
{...props} {...props}
/> />
); );
@@ -74,7 +74,18 @@ export function PageChildren({
/> />
))} ))}
{hasNextPage && ( {hasNextPage && (
<div className={classes.loadMore} onClick={() => fetchNextPage()}> <div
className={classes.loadMore}
onClick={() => fetchNextPage()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fetchNextPage();
}
}}
role="button"
tabIndex={0}
>
{t("Load more")} {t("Load more")}
</div> </div>
)} )}
@@ -70,11 +70,14 @@ function EmojiPicker({
closeOnEscape={true} closeOnEscape={true}
> >
<Popover.Target ref={setTarget}> <Popover.Target ref={setTarget}>
<ActionIcon <ActionIcon
c={actionIconProps?.c || "gray"} c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"} variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size} size={actionIconProps?.size}
onClick={handlers.toggle} onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
aria-expanded={opened}
> >
{icon} {icon}
</ActionIcon> </ActionIcon>
@@ -9,7 +9,7 @@ import classes from "../styles/chat-sidebar.module.css";
type Props = { type Props = {
chat: AiChat; chat: AiChat;
isActive: boolean; isActive: boolean;
onDelete: (chatId: string) => void; onDelete: (chatId: string, title: string | null) => void;
onRename: (chatId: string, title: string) => void; onRename: (chatId: string, title: string) => void;
}; };
@@ -132,6 +132,7 @@ export default function AiChatSidebarItem({
size="xs" size="xs"
color="gray" color="gray"
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
> >
<IconDots size={14} /> <IconDots size={14} />
</ActionIcon> </ActionIcon>
@@ -153,7 +154,7 @@ export default function AiChatSidebarItem({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDelete(chat.id); onDelete(chat.id, chat.title);
}} }}
> >
{t("Delete")} {t("Delete")}
@@ -1,6 +1,14 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core"; import {
ActionIcon,
Center,
Text,
TextInput,
Loader,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react"; import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -73,16 +81,31 @@ export default function AiChatSidebar() {
); );
const handleDelete = useCallback( const handleDelete = useCallback(
(id: string) => { (id: string, title: string | null) => {
deleteMutation.mutate(id, { modals.openConfirmModal({
onSuccess: () => { title: t("Delete chat"),
if (chatId === id) { centered: true,
navigate("/ai"); children: (
} <Text size="sm">
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
title: title || t("Untitled"),
})}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
}, },
}); });
}, },
[deleteMutation, chatId, navigate], [deleteMutation, chatId, navigate, t],
); );
const handleRename = useCallback( const handleRename = useCallback(
@@ -114,7 +137,8 @@ export default function AiChatSidebar() {
<TextInput <TextInput
className={classes.searchInput} className={classes.searchInput}
placeholder="Search chats..." placeholder={t("Search chats...")}
aria-label={t("Search chats")}
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
size="xs" size="xs"
value={search} value={search}
@@ -178,6 +178,7 @@ export default function AsideChatPanel() {
href="/ai" href="/ai"
variant="subtle" variant="subtle"
color="dark" color="dark"
aria-label={t("New chat")}
onClick={handleNewChat} onClick={handleNewChat}
> >
<IconPlus size={20} stroke={1.75} /> <IconPlus size={20} stroke={1.75} />
@@ -185,13 +186,23 @@ export default function AsideChatPanel() {
</Tooltip> </Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}> <Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}> <ActionIcon
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<IconArrowsDiagonal size={18} stroke={1.5} /> <IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Close")} openDelay={250}> <Tooltip label={t("Close")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}> <ActionIcon
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<IconX size={20} stroke={1.75} /> <IconX size={20} stroke={1.75} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -65,7 +65,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
isStreaming={isStreaming} isStreaming={isStreaming}
onSend={onSend} onSend={onSend}
onStop={onStop} onStop={onStop}
placeholder="Ask anything... Use @ to mention pages" placeholder={t("Ask anything... Use @ to mention pages")}
autofocus autofocus
/> />
</div> </div>
@@ -200,7 +200,7 @@ export default function ChatInput({
link: false, link: false,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder || "Ask anything... Use @ to mention pages", placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
}), }),
CharacterCount.configure({ CharacterCount.configure({
limit: 50000, limit: 50000,
@@ -225,6 +225,10 @@ export default function ChatInput({
}), }),
], ],
editorProps: { editorProps: {
attributes: {
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ( if (
@@ -275,6 +279,8 @@ export default function ChatInput({
type="file" type="file"
accept={ACCEPTED_FILE_TYPES} accept={ACCEPTED_FILE_TYPES}
multiple multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }} style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
/> />
@@ -31,7 +31,16 @@ export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
<div className={classes.toolGroup}> <div className={classes.toolGroup}>
<div <div
className={classes.toolGroupHeader} className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)} onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
> >
{activeLabel ? ( {activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} /> <IconLoader2 size={12} className={classes.processingSpinner} />
@@ -98,7 +98,7 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); color: var(--mantine-color-dimmed);
margin-bottom: var(--mantine-spacing-xs); margin-bottom: var(--mantine-spacing-xs);
} }
@@ -125,7 +125,7 @@
.suggestionsLabel { .suggestionsLabel {
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 500; font-weight: 500;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); color: var(--mantine-color-dimmed);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
@@ -43,7 +43,7 @@
margin-top: 6px; margin-top: 6px;
text-align: center; text-align: center;
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); color: var(--mantine-color-dimmed);
} }
.attachmentChips { .attachmentChips {
@@ -36,7 +36,7 @@
padding: 4px var(--mantine-spacing-xs); padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); color: var(--mantine-color-dimmed);
user-select: none; user-select: none;
} }
@@ -104,7 +104,7 @@
.chatItemDate { .chatItemDate {
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); color: var(--mantine-color-dimmed);
white-space: nowrap; white-space: nowrap;
transition: opacity 150ms; transition: opacity 150ms;
} }
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("API key created")} title={t("{{credential}} created", { credential: t("API key") })}
size="lg" size="lg"
> >
<Stack gap="md"> <Stack gap="md">
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
color="red" color="red"
> >
{t( {t(
"Make sure to copy your API key now. You won't be able to see it again!", "Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("API key") },
)} )}
</Alert> </Alert>
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
</div> </div>
<Button fullWidth onClick={onClose} mt="md"> <Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")} {t("I've saved my {{credential}}", { credential: t("API key") })}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>
@@ -44,7 +44,7 @@ export function ApiKeyTable({
<Table.Th>{t("Last used")}</Table.Th> <Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th> <Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th> <Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th> <Table.Th aria-label={t("Action")} />
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -106,7 +106,11 @@ export function ApiKeyTable({
<Table.Td> <Table.Td>
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon
variant="subtle"
color="gray"
aria-label={t("API key menu")}
>
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={handleClose} onClose={handleClose}
title={t("Create API Key")} title={t("Create {{credential}}", { credential: t("API key") })}
size="md" size="md"
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Revoke API key")} title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md" size="md"
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
{t("Are you sure you want to revoke this API key")}{" "} {t("Are you sure you want to revoke this {{credential}}", {
credential: t("API key"),
})}{" "}
<strong>{apiKey?.name}</strong>? <strong>{apiKey?.name}</strong>?
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Update API key")} title={t("Update {{credential}}", { credential: t("API key") })}
size="md" size="md"
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({ return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data), mutationFn: (data) => createApiKey(data),
onSuccess: () => { onSuccess: () => {
notifications.show({ message: t("API key created successfully") }); notifications.show({
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string), ["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key", "api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key", "api_key.deleted": "Deleted API key",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
"space.created": "Created space", "space.created": "Created space",
"space.updated": "Updated space", "space.updated": "Updated space",
"space.deleted": "Deleted space", "space.deleted": "Deleted space",
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" }, { value: "api_key.deleted", label: "Deleted API key" },
], ],
}, },
{
group: "SCIM token",
items: [
{ value: "scim_token.created", label: "Created SCIM token" },
{ value: "scim_token.updated", label: "Updated SCIM token" },
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
],
},
{ {
group: "License", group: "License",
items: [ items: [
+1
View File
@@ -8,6 +8,7 @@ export const Feature = {
AI: 'ai', AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence', CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx', DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing', ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings', SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp', MCP: 'mcp',
@@ -140,7 +140,7 @@ export function PagePermissionList({
)} )}
</Group> </Group>
<ScrollArea mah={250} viewportRef={viewportRef}> <ScrollArea.Autosize mah={400} viewportRef={viewportRef}>
{sortedMembers.map((member) => ( {sortedMembers.map((member) => (
<PagePermissionItem <PagePermissionItem
key={`${member.type}-${member.id}`} key={`${member.type}-${member.id}`}
@@ -158,7 +158,7 @@ export function PagePermissionList({
<Loader size="xs" /> <Loader size="xs" />
</Center> </Center>
)} )}
</ScrollArea> </ScrollArea.Autosize>
</> </>
); );
} }
@@ -1,4 +1,12 @@
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core"; import {
ActionIcon,
Group,
Menu,
Modal,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
IconRosetteDiscountCheckFilled, IconRosetteDiscountCheckFilled,
@@ -38,6 +46,7 @@ export function PageVerificationModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
aria-label={status === "none" ? t("Set up verification") : t("Verify page")}
title={ title={
<Group gap="xs"> <Group gap="xs">
<IconShieldCheck <IconShieldCheck
@@ -97,9 +106,9 @@ export function PageVerificationBadge({
withArrow withArrow
openDelay={250} openDelay={250}
> >
<ActionIcon variant="subtle" color="gray"> <ThemeIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} /> <IconShieldCheck size={20} stroke={1.5} />
</ActionIcon> </ThemeIcon>
</Tooltip> </Tooltip>
); );
} }
@@ -130,7 +139,12 @@ export function PageVerificationBadge({
</Tooltip> </Tooltip>
) : !readOnly ? ( ) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}> <Tooltip label={t("Set up verification")} withArrow openDelay={250}>
<ActionIcon variant="subtle" color="gray" onClick={open}> <ActionIcon
variant="subtle"
color="gray"
aria-label={t("Set up verification")}
onClick={open}
>
<IconShieldCheck size={20} stroke={1.5} /> <IconShieldCheck size={20} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -24,7 +24,7 @@ export default function PdfRenderPage() {
return; return;
} }
fetch('/api/pdf/render', { fetch('/api/pdf-export/render', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pageId, token }), body: JSON.stringify({ pageId, token }),
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface CreateScimTokenModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IScimToken) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
const handleSubmit = async (data: FormValues) => {
try {
const created = await createMutation.mutateAsync({ name: data.name });
onSuccess(created);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,55 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnableScim() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
const hasAccess = useHasFeature(Feature.SCIM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,61 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface RevokeScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function RevokeScimTokenModal({
opened,
onClose,
scimToken,
}: RevokeScimTokenModalProps) {
const { t } = useTranslation();
const revokeMutation = useRevokeScimTokenMutation();
const handleRevoke = async () => {
if (!scimToken) return;
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,69 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenCreatedModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function ScimTokenCreatedModal({
opened,
onClose,
scimToken,
}: ScimTokenCreatedModalProps) {
const { t } = useTranslation();
if (!scimToken) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("SCIM token") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -0,0 +1,130 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenTableProps {
tokens: IScimToken[];
isLoading?: boolean;
onUpdate?: (token: IScimToken) => void;
onRevoke?: (token: IScimToken) => void;
}
export function ScimTokenTable({
tokens,
isLoading,
onUpdate,
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,30 @@
import { Group, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
export function ScimUrlPanel() {
const { t } = useTranslation();
const scimUrl = `${window.location.origin}/api/scim/v2`;
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -0,0 +1,77 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function UpdateScimTokenModal({
opened,
onClose,
scimToken,
}: UpdateScimTokenModalProps) {
const { t } = useTranslation();
const updateMutation = useUpdateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
useEffect(() => {
if (opened && scimToken) {
form.setValues({ name: scimToken.name });
}
}, [opened, scimToken]);
const handleSubmit = async (data: FormValues) => {
if (!scimToken) return;
await updateMutation.mutateAsync({
tokenId: scimToken.id,
name: data.name,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -0,0 +1,96 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createScimToken,
getScimTokens,
revokeScimToken,
updateScimToken,
} from "@/ee/scim/services/scim-token-service";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetScimTokensQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
mutationFn: (data) => createScimToken(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("SCIM token"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateScimTokenRequest>({
mutationFn: (data) => updateScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRevokeScimTokenRequest>({
mutationFn: (data) => revokeScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,34 @@
import api from "@/lib/api-client";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getScimTokens(
params?: QueryParams,
): Promise<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -0,0 +1,27 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IScimToken {
id: string;
name: string;
token?: string;
tokenLastFour: string;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
lastUsedAt: string | null;
createdAt: string;
creator?: Partial<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return ( return (
<> <>
<Card shadow="sm" radius="sm"> <Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600}> <Table.ScrollContainer minWidth={600} maxHeight={400}>
<Table verticalSpacing="sm"> <Table verticalSpacing="sm" stickyHeader>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Name")}</Table.Th> <Table.Th>{t("Name")}</Table.Th>
@@ -141,6 +141,7 @@ export default function SsoProviderList() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label={t("Edit {{name}}", { name: provider.name })}
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
> >
<IconPencil size={16} /> <IconPencil size={16} />
@@ -152,7 +153,13 @@ export default function SsoProviderList() {
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon
variant="subtle"
color="gray"
aria-label={t("More actions for {{name}}", {
name: provider.name,
})}
>
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
+137 -6
View File
@@ -1,8 +1,18 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts"; import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core"; import {
import React from "react"; Alert,
Button,
Card,
Divider,
Group,
Space,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React, { useState } from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx"; import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"; import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
@@ -12,16 +22,41 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const SCIM_TOKEN_LIMIT = 5;
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM); const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION); const hasScim = useHasFeature(Feature.SCIM);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS); const [workspace] = useAtom(workspaceAtom);
const isScimEnabled = workspace?.isScimEnabled ?? false;
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
hasScim && isScimEnabled ? { cursor } : undefined,
);
const [createOpen, setCreateOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -45,7 +80,7 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) {t("Single sign-on (SSO)")}
</Title> </Title>
<EnforceSso /> <EnforceSso />
@@ -66,6 +101,102 @@ export default function Security() {
)} )}
<SsoProviderList /> <SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</> </>
); );
} }
@@ -56,6 +56,7 @@ export default function TemplateCard({
color="gray" color="gray"
className={classes.menuTarget} className={classes.menuTarget}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={t("Template menu")}
> >
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
@@ -24,7 +24,7 @@ export default function TemplatePreviewModal({
const title = template?.title || t("Untitled"); const title = template?.title || t("Untitled");
return ( return (
<Modal.Root size={1200} opened={opened} onClose={onClose}> <Modal.Root size={1200} opened={opened} onClose={onClose} aria-label={title}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header> <Modal.Header>
@@ -144,6 +144,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
withCloseButton withCloseButton
withBorder withBorder
data-comment-dialog data-comment-dialog
aria-label={t("Add comment")}
> >
<Stack gap={2}> <Stack gap={2}>
<Group> <Group>
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command"; import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view"; import MentionView from "@/features/editor/components/mention/mention-view";
import { platformModifierKey } from "@/lib";
interface CommentEditorProps { interface CommentEditorProps {
defaultContent?: any; defaultContent?: any;
@@ -83,7 +84,7 @@ const CommentEditor = forwardRef(
} }
} }
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { if (platformModifierKey(event) && event.code === "Enter") {
event.preventDefault(); event.preventDefault();
if (onSave) onSave(); if (onSave) onSave();
@@ -173,6 +173,15 @@ function CommentListItem({
<Box <Box
className={classes.textSelection} className={classes.textSelection}
onClick={() => handleCommentClick(comment)} onClick={() => handleCommentClick(comment)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCommentClick(comment);
}
}}
role="button"
tabIndex={0}
aria-label={t("Jump to comment selection")}
> >
<Text size="sm">{comment?.selection}</Text> <Text size="sm">{comment?.selection}</Text>
</Box> </Box>
@@ -46,7 +46,11 @@ function CommentMenu({
return ( return (
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" style={{ border: "none" }}> <ActionIcon
variant="default"
style={{ border: "none" }}
aria-label={t("Comment menu")}
>
<IconDots size={20} stroke={2} /> <IconDots size={20} stroke={2} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -19,7 +19,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
}, },
validateFn: (file, allowMedia: boolean) => { validateFn: (file, allowMedia: boolean) => {
if ( if (
(file.type.includes("image/") || file.type.includes("video/")) && (file.type.includes("image/") ||
file.type.includes("video/") ||
file.type === "application/pdf") &&
!allowMedia !allowMedia
) { ) {
return false; return false;
@@ -36,6 +36,7 @@ export default function AudioView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={safeSrc} src={safeSrc}
aria-label={placeholder?.name || t("Audio")}
/> />
)} )}
{!safeSrc && previewSrc && ( {!safeSrc && previewSrc && (
@@ -45,6 +46,7 @@ export default function AudioView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={previewSrc} src={previewSrc}
aria-label={placeholder?.name || t("Audio")}
/> />
<Loader size={20} pos="absolute" top={6} right={6} /> <Loader size={20} pos="absolute" top={6} right={6} />
</Group> </Group>
@@ -60,7 +62,7 @@ export default function AudioView(props: NodeViewProps) {
</Group> </Group>
)} )}
{!safeSrc && !previewSrc && !placeholder && ( {!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls /> <audio className={classes.audio} controls aria-label={t("Audio")} />
)} )}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
@@ -172,6 +172,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
fontWeight: 500, fontWeight: 500,
fontSize: rem(16), fontSize: rem(16),
}} }}
aria-label={t("Text color")}
aria-haspopup="dialog"
aria-expanded={isOpen}
> >
A A
</Button> </Button>
@@ -186,20 +189,32 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
{t("Text color")} {t("Text color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => ( {TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
onClick={() => { role="button"
if (name === "Default") { tabIndex={0}
editor.commands.unsetColor(); aria-label={t(name)}
} else { aria-pressed={!!editorState[`text_${color}`]}
editor onClick={applyTextColor}
.chain() onKeyDown={(e) => {
.focus() if (e.key === "Enter" || e.key === " ") {
.setColor(color || "") e.preventDefault();
.run(); applyTextColor();
} }
setIsOpen(false);
}} }}
style={{ style={{
width: rem(28), width: rem(28),
@@ -221,7 +236,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A A
</Box> </Box>
</Tooltip> </Tooltip>
))} );
})}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
@@ -230,23 +246,35 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
{t("Highlight color")} {t("Highlight color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => ( {HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
onClick={() => { role="button"
if (name === "Default") { tabIndex={0}
editor.commands.unsetHighlight(); aria-label={t(name)}
} else { aria-pressed={!!editorState[`highlight_${color}`]}
editor onClick={applyHighlight}
.chain() onKeyDown={(e) => {
.focus() if (e.key === "Enter" || e.key === " ") {
.toggleMark("highlight", { e.preventDefault();
color: color || "", applyHighlight();
colorName: name.toLowerCase() || "",
})
.run();
} }
setIsOpen(false);
}} }}
style={{ style={{
width: rem(28), width: rem(28),
@@ -274,7 +302,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)} )}
</Box> </Box>
</Tooltip> </Tooltip>
))} );
})}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
@@ -12,6 +12,7 @@ import {
IconInfoCircle, IconInfoCircle,
IconList, IconList,
IconListNumbers, IconListNumbers,
IconQuote,
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core"; import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
@@ -59,6 +60,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isCodeBlock: ctx.editor.isActive("codeBlock"), isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"), isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"), isDetails: ctx.editor.isActive("details"),
isTransclusionSource: ctx.editor.isActive("transclusionSource"),
}; };
}, },
}); });
@@ -122,6 +124,12 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.run(), .run(),
isActive: () => editorState?.isBlockquote, isActive: () => editorState?.isBlockquote,
}, },
{
name: "Synced block",
icon: IconQuote,
command: () => editor.chain().focus().toggleTransclusionSource().run(),
isActive: () => editorState?.isTransclusionSource,
},
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
@@ -149,7 +157,12 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}> <Tooltip
label={t("Turn into")}
withArrow
withinPortal={false}
disabled={isOpen}
>
<Button <Button
className={classes.buttonRoot} className={classes.buttonRoot}
variant="default" variant="default"
@@ -157,6 +170,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
aria-label={t("Turn into")}
aria-haspopup="menu"
aria-expanded={isOpen}
> >
{t(activeItem?.name)} {t(activeItem?.name)}
</Button> </Button>
@@ -92,6 +92,9 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
aria-label={t("Text align")}
aria-haspopup="menu"
aria-expanded={isOpen}
> >
<activeItem.icon style={{ width: rem(16) }} stroke={2} /> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
@@ -137,7 +137,13 @@ export default function DrawioView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}> <Modal.Root
opened={opened}
onClose={handleClose}
fullScreen
closeOnEscape={false}
aria-label={t("Diagram editor")}
>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body pos="relative"> <Modal.Body pos="relative">
@@ -107,7 +107,17 @@ const EmojiList = ({
}, [selectedIndex]); }, [selectedIndex]);
return items.length > 0 || isLoading ? ( return items.length > 0 || isLoading ? (
<Paper id="emoji-command" p="0" shadow="md" withBorder> <Paper
id="emoji-command"
p="0"
shadow="md"
withBorder
role="listbox"
aria-label="Emoji results"
aria-activedescendant={
items.length > 0 ? `emoji-command-option-${selectedIndex}` : undefined
}
>
{isLoading && <Loader m="xs" color="blue" type="dots" />} {isLoading && <Loader m="xs" color="blue" type="dots" />}
{items.length > 0 && ( {items.length > 0 && (
<ScrollArea.Autosize <ScrollArea.Autosize
@@ -120,6 +130,10 @@ const EmojiList = ({
{items.map((item, index: number) => ( {items.map((item, index: number) => (
<ActionIcon <ActionIcon
data-item-index={index} data-item-index={index}
id={`emoji-command-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
aria-label={item.id}
variant="transparent" variant="transparent"
key={item.id} key={item.id}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
@@ -102,6 +102,14 @@ export const LinkEditorPanel = ({
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />} leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
classNames={{ input: classes.linkInput }} classNames={{ input: classes.linkInput }}
placeholder={t("Paste link or search pages")} placeholder={t("Paste link or search pages")}
aria-label={t("Paste link or search pages")}
role="combobox"
aria-expanded={showDropdown}
aria-controls="link-editor-results"
aria-autocomplete="list"
aria-activedescendant={
showDropdown ? `link-editor-option-${selectedIndex}` : undefined
}
value={state.url} value={state.url}
onChange={state.onChange} onChange={state.onChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -125,10 +133,16 @@ export const LinkEditorPanel = ({
scrollbarSize={6} scrollbarSize={6}
mt={state.url.length > 0 ? 8 : 0} mt={state.url.length > 0 ? 8 : 0}
styles={{ content: { minWidth: 0 } }} styles={{ content: { minWidth: 0 } }}
id="link-editor-results"
role="listbox"
aria-label={t("Link suggestions")}
> >
{showUrlItem && ( {showUrlItem && (
<UnstyledButton <UnstyledButton
data-item-index={0} data-item-index={0}
id="link-editor-option-0"
role="option"
aria-selected={selectedIndex === 0}
onClick={() => onSetLink(state.url, false)} onClick={() => onSetLink(state.url, false)}
className={clsx(classes.searchItem, { className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: selectedIndex === 0, [classes.selectedSearchItem]: selectedIndex === 0,
@@ -156,6 +170,9 @@ export const LinkEditorPanel = ({
return ( return (
<UnstyledButton <UnstyledButton
data-item-index={itemIndex} data-item-index={itemIndex}
id={`link-editor-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
key={page.id || index} key={page.id || index}
onClick={() => selectPage(page)} onClick={() => selectPage(page)}
className={clsx(classes.searchItem, { className={clsx(classes.searchItem, {
@@ -287,7 +287,16 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
); );
return ( return (
<Paper id="mention" shadow="md" withBorder radius="md" py={6}> <Paper
id="mention"
shadow="md"
withBorder
radius="md"
py={6}
role="listbox"
aria-label={t("Mention suggestions")}
aria-activedescendant={`mention-option-${selectedIndex}`}
>
<ScrollArea.Autosize <ScrollArea.Autosize
viewportRef={viewportRef} viewportRef={viewportRef}
mah={350} mah={350}
@@ -301,7 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (item.entityType === "header") { if (item.entityType === "header") {
const isFirst = index === 0; const isFirst = index === 0;
return ( return (
<div key={`${item.label}-${index}`}> <div key={`${item.label}-${index}`} role="presentation">
{!isFirst && <Divider my={6} />} {!isFirst && <Divider my={6} />}
<Text <Text
c="dimmed" c="dimmed"
@@ -322,6 +331,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<UnstyledButton <UnstyledButton
data-item-index={index} data-item-index={index}
key={index} key={index}
id={`mention-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectItem(index)} onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: index === selectedIndex,
@@ -348,6 +360,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<UnstyledButton <UnstyledButton
data-item-index={index} data-item-index={index}
key={index} key={index}
id={`mention-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectItem(index)} onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: index === selectedIndex,
@@ -358,7 +373,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
component="div" component="div"
aria-label={item.label} aria-hidden="true"
color="gray" color="gray"
size="sm" size="sm"
> >
@@ -390,6 +405,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
{(hasUsers || hasPages) && <Divider my={6} />} {(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton <UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)} data-item-index={renderItems.indexOf(createPageItemData)}
id={`mention-option-${renderItems.indexOf(createPageItemData)}`}
role="option"
aria-selected={
renderItems.indexOf(createPageItemData) === selectedIndex
}
onClick={() => onClick={() =>
selectItem(renderItems.indexOf(createPageItemData)) selectItem(renderItems.indexOf(createPageItemData))
} }
@@ -405,6 +425,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
component="div" component="div"
color="gray" color="gray"
size="sm" size="sm"
aria-hidden="true"
> >
<IconPlus size={16} stroke={1.5} /> <IconPlus size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -92,7 +92,20 @@ export default function PdfView(props: NodeViewProps) {
if (hasError) { if (hasError) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}> <div
data-pdf-error
className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })}
onClick={handleSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
}}
role="button"
tabIndex={0}
aria-label={t("Failed to load PDF")}
>
<IconFileTypePdf size={32} stroke={1.5} /> <IconFileTypePdf size={32} stroke={1.5} />
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("Failed to load PDF")} {t("Failed to load PDF")}
@@ -187,12 +187,14 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
position={{ top: 90, right: 50 }} position={{ top: 90, right: 50 }}
withBorder withBorder
transitionProps={{ transition: "slide-down" }} transitionProps={{ transition: "slide-down" }}
aria-label={t("Find and replace")}
> >
<Stack gap="xs"> <Stack gap="xs">
<Flex align="center" gap="xs"> <Flex align="center" gap="xs">
<Input <Input
ref={inputRef} ref={inputRef}
placeholder={t("Find")} placeholder={t("Find")}
aria-label={t("Find")}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
rightSection={ rightSection={
<Text size="xs" ta="right"> <Text size="xs" ta="right">
@@ -217,7 +219,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<ActionIcon.Group> <ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}> <Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={previous}> <ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label={t("Previous match (Shift+Enter)")}
>
<IconArrowNarrowUp <IconArrowNarrowUp
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
stroke={1.5} stroke={1.5}
@@ -225,7 +232,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Next match (Enter)")}> <Tooltip label={t("Next match (Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={next}> <ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label={t("Next match (Enter)")}
>
<IconArrowNarrowDown <IconArrowNarrowDown
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
stroke={1.5} stroke={1.5}
@@ -237,6 +249,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
variant="subtle" variant="subtle"
color={caseSensitive.color} color={caseSensitive.color}
onClick={() => caseSensitiveToggle()} onClick={() => caseSensitiveToggle()}
aria-label={t("Match case (Alt+C)")}
aria-pressed={caseSensitive.isCaseSensitive}
> >
<IconLetterCase <IconLetterCase
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
@@ -250,6 +264,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
variant="subtle" variant="subtle"
color={replaceButton.color} color={replaceButton.color}
onClick={() => replaceButtonToggle()} onClick={() => replaceButtonToggle()}
aria-label={t("Replace")}
aria-pressed={replaceButton.isReplaceShow}
> >
<IconReplace <IconReplace
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
@@ -259,7 +275,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</Tooltip> </Tooltip>
)} )}
<Tooltip label={t("Close (Escape)")}> <Tooltip label={t("Close (Escape)")}>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}> <ActionIcon
variant="subtle"
color="gray"
onClick={closeDialog}
aria-label={t("Close (Escape)")}
>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} /> <IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -269,6 +290,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<Flex align="center" gap="xs"> <Flex align="center" gap="xs">
<Input <Input
placeholder={t("Replace")} placeholder={t("Replace")}
aria-label={t("Replace")}
leftSection={<IconReplace size={16} />} leftSection={<IconReplace size={16} />}
rightSection={<div></div>} rightSection={<div></div>}
rightSectionPointerEvents="all" rightSectionPointerEvents="all"
@@ -86,7 +86,15 @@ const CommandList = ({
}, [selectedIndex]); }, [selectedIndex]);
return flatItems.length > 0 ? ( return flatItems.length > 0 ? (
<Paper id="slash-command" shadow="md" p="xs" withBorder> <Paper
id="slash-command"
shadow="md"
p="xs"
withBorder
role="listbox"
aria-label={t("Slash commands")}
aria-activedescendant={`slash-command-option-${selectedIndex}`}
>
<ScrollArea <ScrollArea
viewportRef={viewportRef} viewportRef={viewportRef}
h={350} h={350}
@@ -94,22 +102,30 @@ const CommandList = ({
scrollbarSize={8} scrollbarSize={8}
overscrollBehavior="contain" overscrollBehavior="contain"
> >
{Object.entries(items).map(([category, categoryItems]) => ( {(() => {
<div key={category}> let flatIndex = -1;
return Object.entries(items).map(([category, categoryItems]) => (
<div key={category} role="group" aria-label={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize"> <Text c="dimmed" mb={4} fw={500} tt="capitalize">
{category} {category}
</Text> </Text>
{categoryItems.map((item: SlashMenuItemType, index: number) => ( {categoryItems.map((item: SlashMenuItemType) => {
flatIndex += 1;
const itemIndex = flatIndex;
return (
<UnstyledButton <UnstyledButton
data-item-index={index} data-item-index={itemIndex}
key={index} key={itemIndex}
onClick={() => selectItem(index)} id={`slash-command-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: itemIndex === selectedIndex,
})} })}
> >
<Group> <Group>
<ActionIcon variant="default" component="div"> <ActionIcon variant="default" component="div" aria-hidden="true">
<item.icon size={18} /> <item.icon size={18} />
</ActionIcon> </ActionIcon>
@@ -124,9 +140,11 @@ const CommandList = ({
</div> </div>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
))} );
})}
</div> </div>
))} ));
})()}
</ScrollArea> </ScrollArea>
</Paper> </Paper>
) : null; ) : null;
@@ -25,6 +25,7 @@ import {
IconColumns3, IconColumns3,
IconColumns2, IconColumns2,
IconTag, IconTag,
IconRotate2,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -132,7 +133,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Numbered list", title: "Numbered list",
description: "Create a list with numbering.", description: "Create a list with numbering.",
searchTerms: ["numbered", "ordered", "list"], searchTerms: ["numbered", "ordered", "list", "ol"],
icon: IconListNumbers, icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run(); editor.chain().focus().deleteRange(range).toggleOrderedList().run();
@@ -231,7 +232,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Audio", title: "Audio",
description: "Upload any audio from your device.", description: "Upload any audio from your device.",
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"], searchTerms: [
"audio",
"music",
"sound",
"mp3",
"media",
"file",
"attachment",
],
icon: IconMusic, icon: IconMusic,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@@ -471,12 +480,41 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Subpages (Child pages)", title: "Subpages (Child pages)",
description: "List all subpages of the current page", description: "List all subpages of the current page",
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"], searchTerms: [
"subpages",
"child",
"children",
"nested",
"hierarchy",
"toc",
],
icon: IconSitemap, icon: IconSitemap,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run(); editor.chain().focus().deleteRange(range).insertSubpages().run();
}, },
}, },
{
title: "Synced block",
description: "Create a block that stays in sync across pages.",
searchTerms: [
"sync",
"synced",
"synced block",
"excerpt",
"transclusion",
"reusable",
"snippet",
],
icon: IconRotate2,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTransclusionSource()
.run();
},
},
{ {
title: "2 Columns", title: "2 Columns",
description: "Split content into two columns.", description: "Split content into two columns.",
@@ -92,8 +92,17 @@ export default function StatusView(props: NodeViewProps) {
colorClassMap[color], colorClassMap[color],
)} )}
onClick={() => isEditable && setOpened(true)} onClick={() => isEditable && setOpened(true)}
onKeyDown={(e) => {
if (isEditable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setOpened(true);
}
}}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={text || "SET STATUS"}
aria-haspopup="dialog"
aria-expanded={opened}
> >
{text || "SET STATUS"} {text || "SET STATUS"}
</span> </span>
@@ -127,6 +136,16 @@ export default function StatusView(props: NodeViewProps) {
)} )}
style={{ backgroundColor: bg }} style={{ backgroundColor: bg }}
onClick={() => handleColorChange(name)} onClick={() => handleColorChange(name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleColorChange(name);
}
}}
role="button"
tabIndex={0}
aria-label={name}
aria-pressed={color === name}
> >
{color === name && <IconCheck size={14} />} {color === name && <IconCheck size={14} />}
</Box> </Box>
@@ -0,0 +1,17 @@
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./transclusion.module.css";
export default function ErrorPlaceholder() {
const { t } = useTranslation();
return (
<div className={classes.placeholder}>
<IconAlertTriangle
size={18}
stroke={1.6}
className={classes.placeholderIcon}
/>
<span>{t("Failed to load this synced block")}</span>
</div>
);
}
@@ -0,0 +1,13 @@
import { IconEyeOff } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./transclusion.module.css";
export default function NoAccessPlaceholder() {
const { t } = useTranslation();
return (
<div className={classes.placeholder}>
<IconEyeOff size={18} stroke={1.6} className={classes.placeholderIcon} />
<span>{t("You don't have access to this synced block")}</span>
</div>
);
}
@@ -0,0 +1,17 @@
import { IconInfoCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./transclusion.module.css";
export default function NotFoundPlaceholder() {
const { t } = useTranslation();
return (
<div className={classes.placeholder}>
<IconInfoCircle
size={18}
stroke={1.6}
className={classes.placeholderIcon}
/>
<span>{t("The original synced block no longer exists")}</span>
</div>
);
}
@@ -0,0 +1,48 @@
import { EditorProvider } from "@tiptap/react";
import { useMemo } from "react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
type Props = {
content: unknown;
};
export default function TransclusionContent({ content }: Props) {
const extensions = useMemo(() => {
const filtered = mainExtensions.filter(
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
);
return [
...filtered,
UniqueID.configure({
types: ["heading", "paragraph", "transclusionSource"],
updateDocument: false,
}),
];
}, []);
// Isolate the nested read-only editor's events from the host editor:
// - mousedown/click would otherwise make the host node-select the atom
// wrapper, blocking native text selection inside.
// - dragstart/dragover/drop would otherwise let the host treat events
// inside the nested view as drops on the host, duplicating dropped
// files at the transclusion's position.
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
return (
<div
onMouseDown={stop}
onClick={stop}
onDragStart={stop}
onDragOver={stop}
onDrop={stop}
>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={content as any}
/>
</div>
);
}
@@ -0,0 +1,213 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
lookupTransclusion,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
type LookupKey = string; // `${sourcePageId}::${transclusionId}`
type Subscriber = {
key: LookupKey;
sourcePageId: string;
transclusionId: string;
setResult: (r: TransclusionLookup) => void;
};
type ContextValue = {
/** Register a subscriber. Returns an unsubscribe function. */
subscribe: (s: Subscriber) => () => void;
/**
* Force a re-fetch of `key` and resolve when the response arrives (or the
* request fails). Bypasses the cache and any in-flight de-dup so the user
* always sees a fresh server read.
*/
refresh: (key: LookupKey) => Promise<void>;
};
const TransclusionLookupContext = createContext<ContextValue | null>(null);
export function TransclusionLookupProvider({
children,
shareId,
}: {
children: React.ReactNode;
/**
* When set, lookups go through the share-scoped public endpoint and are
* gated by the share graph (source page must have its own share or inherit
* one). Used by the public share viewer; left undefined in the authenticated
* app, where personal permissions gate access.
*/
shareId?: string;
}) {
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
const queueRef = useRef(new Set<LookupKey>());
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read inside flush() via ref so changing share context doesn't churn the
// memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef<string | undefined>(shareId);
shareIdRef.current = shareId;
// Last looked-up value for each key. Re-subscribers (e.g. when the editor
// remounts after switching from static to live) get this immediately
// instead of triggering a duplicate fetch.
const resultCacheRef = useRef(new Map<LookupKey, TransclusionLookup>());
// Keys that are currently in flight in a batch request. A second subscribe
// for the same key while the first request is pending is a no-op; the
// subscriber is added to subscribersRef and will be notified when the
// pending request completes.
const inFlightRef = useRef(new Set<LookupKey>());
// Resolvers waiting on the next response for a key. Populated by refresh()
// so callers can await the fetch round-trip; resolved on success and on
// network error so the UI never hangs in a loading state.
const pendingRef = useRef(new Map<LookupKey, Array<() => void>>());
const flush = useCallback(async () => {
tickRef.current = null;
const keys = Array.from(queueRef.current);
queueRef.current.clear();
if (keys.length === 0) return;
for (const k of keys) inFlightRef.current.add(k);
const references = keys.map((k) => {
const [sourcePageId, transclusionId] = k.split("::");
return { sourcePageId, transclusionId };
});
const resolveWaiters = (key: LookupKey) => {
const waiters = pendingRef.current.get(key);
if (!waiters) return;
pendingRef.current.delete(key);
for (const w of waiters) w();
};
try {
const activeShareId = shareIdRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
inFlightRef.current.delete(key);
const subs = subscribersRef.current.get(key);
if (subs) {
for (const s of subs) s.setResult(r);
}
resolveWaiters(key);
}
} catch {
// Network error — leave subscribers in pending state and clear the
// in-flight flag so a future subscribe can retry.
for (const k of keys) {
inFlightRef.current.delete(k);
resolveWaiters(k);
}
}
}, []);
const enqueue = useCallback(
(key: LookupKey) => {
queueRef.current.add(key);
if (tickRef.current === null) {
tickRef.current = setTimeout(flush, 10);
}
},
[flush],
);
const subscribe = useCallback<ContextValue["subscribe"]>(
(s) => {
const list = subscribersRef.current.get(s.key) ?? [];
list.push(s);
subscribersRef.current.set(s.key, list);
const cached = resultCacheRef.current.get(s.key);
if (cached) {
s.setResult(cached);
} else if (!inFlightRef.current.has(s.key)) {
enqueue(s.key);
}
return () => {
const cur = subscribersRef.current.get(s.key) ?? [];
const next = cur.filter((x) => x !== s);
if (next.length === 0) subscribersRef.current.delete(s.key);
else subscribersRef.current.set(s.key, next);
};
},
[enqueue],
);
const refresh = useCallback<ContextValue["refresh"]>(
(key) =>
new Promise<void>((resolve) => {
resultCacheRef.current.delete(key);
inFlightRef.current.delete(key);
const waiters = pendingRef.current.get(key) ?? [];
waiters.push(resolve);
pendingRef.current.set(key, waiters);
enqueue(key);
}),
[enqueue],
);
useEffect(
() => () => {
if (tickRef.current) clearTimeout(tickRef.current);
},
[],
);
const value = useMemo<ContextValue>(
() => ({ subscribe, refresh }),
[subscribe, refresh],
);
return (
<TransclusionLookupContext.Provider value={value}>
{children}
</TransclusionLookupContext.Provider>
);
}
export function useTransclusionLookup(
sourcePageId: string | null | undefined,
transclusionId: string | null | undefined,
): {
result: TransclusionLookup | null;
refresh: () => Promise<void>;
} {
const ctx = useContext(TransclusionLookupContext);
const [result, setResult] = useState<TransclusionLookup | null>(null);
useEffect(() => {
if (!ctx || !sourcePageId || !transclusionId) return;
const key = `${sourcePageId}::${transclusionId}`;
const unsubscribe = ctx.subscribe({
key,
sourcePageId,
transclusionId,
setResult,
});
return unsubscribe;
}, [ctx, sourcePageId, transclusionId]);
const refresh = useCallback(async () => {
if (!ctx || !sourcePageId || !transclusionId) return;
await ctx.refresh(`${sourcePageId}::${transclusionId}`);
}, [ctx, sourcePageId, transclusionId]);
return { result, refresh };
}
@@ -0,0 +1,212 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconDots,
IconLinkOff,
IconPencil,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ErrorBoundary } from "react-error-boundary";
import { useTransclusionLookup } from "./transclusion-lookup-context";
import TransclusionContent from "./transclusion-content";
import NoAccessPlaceholder from "./no-access-placeholder";
import NotFoundPlaceholder from "./not-found-placeholder";
import ErrorPlaceholder from "./error-placeholder";
import classes from "./transclusion.module.css";
import SyncBlockReferencesDropdown from "@/features/transclusion/components/sync-block-references-dropdown";
import {
useReferencesQuery,
useUnsyncReferenceMutation,
} from "@/features/transclusion/queries/transclusion-query";
import { buildPageUrl } from "@/features/page/page.utils";
export default function TransclusionReferenceView(props: NodeViewProps) {
const isEditable = props.editor.isEditable;
const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null;
const transclusionId: string | null = props.node.attrs.transclusionId ?? null;
const [openMenus, setOpenMenus] = useState(0);
const trackOpen = (open: boolean) =>
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
return (
<NodeViewWrapper
className={classes.includeWrap}
data-focused={isEditable && props.selected ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
contentEditable={false}
>
<ErrorBoundary
resetKeys={[sourcePageId, transclusionId]}
fallback={<ErrorPlaceholder />}
>
<TransclusionReferenceBody {...props} trackOpen={trackOpen} />
</ErrorBoundary>
</NodeViewWrapper>
);
}
function TransclusionReferenceBody({
editor,
node,
deleteNode,
getPos,
trackOpen,
}: NodeViewProps & { trackOpen: (open: boolean) => void }) {
const { t } = useTranslation();
const sourcePageId: string | null = node.attrs.sourcePageId ?? null;
const transclusionId: string | null = node.attrs.transclusionId ?? null;
const isEditable = editor.isEditable;
const { result, refresh } = useTransclusionLookup(
sourcePageId,
transclusionId,
);
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
};
// @ts-ignore - editor.storage.pageId is set by the host editor
const hostPageId: string | undefined = editor.storage?.pageId;
const unsyncMutation = useUnsyncReferenceMutation();
// Cached against the dropdown's identical query so the source link target
// is ready as soon as the controls fade in on hover, without a second
// fetch.
const referencesQuery = useReferencesQuery(
sourcePageId,
transclusionId,
isEditable,
);
const sourcePageHref = (() => {
const source = referencesQuery.data?.source;
const base = source?.spaceSlug
? buildPageUrl(source.spaceSlug, source.slugId, source.title)
: sourcePageId
? `/p/${sourcePageId}`
: null;
if (!base) return null;
return transclusionId ? `${base}#${transclusionId}` : base;
})();
const handleUnsync = async () => {
if (!hostPageId || !sourcePageId || !transclusionId) return;
try {
const { content } = await unsyncMutation.mutateAsync({
referencePageId: hostPageId,
sourcePageId,
transclusionId,
});
const pos = getPos();
if (typeof pos !== "number") return;
const from = pos;
const to = pos + node.nodeSize;
editor
.chain()
.focus()
.insertContentAt({ from, to }, content as any)
.run();
} catch {
// mutation surfaces errors via React Query; node stays as-is
}
};
return (
<>
{isEditable && (
<div
className={classes.includeControls}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
{sourcePageId && transclusionId && hostPageId && (
<SyncBlockReferencesDropdown
sourcePageId={sourcePageId}
transclusionId={transclusionId}
currentPageId={hostPageId}
mode="reference"
onOpenChange={trackOpen}
/>
)}
<span className={classes.controlsDivider} />
<Tooltip label={t("Refresh")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleRefresh}
loading={refreshing}
disabled={!sourcePageId || !transclusionId}
>
<IconRefresh size={14} />
</ActionIcon>
</Tooltip>
{sourcePageHref && (
<Tooltip label={t("Edit source")}>
<ActionIcon
component={Link}
to={sourcePageHref}
variant="subtle"
color="gray"
size="sm"
style={{
textDecoration: "none",
borderBottom: "none",
}}
>
<IconPencil size={14} />
</ActionIcon>
</Tooltip>
)}
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLinkOff size={14} />}
onClick={handleUnsync}
disabled={
unsyncMutation.isPending ||
!hostPageId ||
!sourcePageId ||
!transclusionId
}
>
{t("Unsync")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => deleteNode()}
>
{t("Remove from page")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
)}
{!sourcePageId || !transclusionId ? (
<NotFoundPlaceholder />
) : !result ? (
<div style={{ minHeight: 24 }} />
) : !("status" in result) ? (
<TransclusionContent content={result.content} />
) : result.status === "no_access" ? (
<NoAccessPlaceholder />
) : (
<NotFoundPlaceholder />
)}
</>
);
}
@@ -0,0 +1,126 @@
import {
NodeViewContent,
NodeViewProps,
NodeViewWrapper,
} from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconCheck,
IconCopy,
IconDots,
IconLinkOff,
IconTrash,
} from "@tabler/icons-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import classes from "./transclusion.module.css";
import SyncBlockReferencesDropdown from "@/features/transclusion/components/sync-block-references-dropdown";
export default function TransclusionView(props: NodeViewProps) {
const { editor, node, deleteNode } = props;
const { t } = useTranslation();
const [openMenus, setOpenMenus] = useState(0);
const trackOpen = (open: boolean) =>
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
const isEditable = editor.isEditable;
// @ts-ignore - editor.storage.pageId is set by the host editor (page-editor.tsx onCreate)
const sourcePageId: string | undefined = editor.storage?.pageId;
const transclusionId: string | null = node.attrs.id ?? null;
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (!sourcePageId || !transclusionId) return;
const html = `<div data-type="transclusionReference" data-source-page-id="${sourcePageId}" data-transclusion-id="${transclusionId}"></div>`;
try {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([html], { type: "text/plain" }),
}),
]);
} catch {
// Fallback for browsers without ClipboardItem write support
try {
await navigator.clipboard.writeText(html);
} catch {
return;
}
}
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
notifications.show({
message: t("Copied. Paste on any page to embed this synced block."),
});
};
const handleUnsync = () => {
editor.chain().focus().unsyncTransclusionSource().run();
};
return (
<NodeViewWrapper
className={classes.transclusionWrap}
data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined}
>
{isEditable && (
<div
className={classes.transclusionControls}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
{sourcePageId && transclusionId && (
<SyncBlockReferencesDropdown
sourcePageId={sourcePageId}
transclusionId={transclusionId}
currentPageId={sourcePageId}
mode="source"
onOpenChange={trackOpen}
/>
)}
<span className={classes.controlsDivider} />
<Tooltip label={copied ? t("Copied") : t("Copy synced block")}>
<ActionIcon
variant="subtle"
color={copied ? "teal" : "gray"}
size="sm"
onClick={handleCopy}
disabled={!sourcePageId || !transclusionId}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLinkOff size={14} />}
onClick={handleUnsync}
>
{t("Unsync")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => deleteNode()}
>
{t("Delete synced block")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
)}
<NodeViewContent />
</NodeViewWrapper>
);
}
@@ -0,0 +1,209 @@
.placeholder {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--mantine-radius-md);
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
user-select: none;
}
.placeholderIcon {
flex: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.transclusionBadge {
display: inline-block;
padding: 2px 8px;
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
border-radius: var(--mantine-radius-sm);
margin-bottom: 4px;
}
.transclusionWrap {
position: relative;
margin-left: -3rem;
margin-right: -3rem;
width: calc(100% + 6rem);
padding: 0.5em 3rem;
border-radius: 8px;
border: 2px solid transparent;
transition: border 0.3s;
}
.transclusionWrap:hover,
.transclusionWrap:focus-within {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
color-mix(in srgb, var(--mantine-color-orange-9), transparent 55%)
);
}
.transclusionControls {
position: absolute;
bottom: calc(100% + 8px);
right: 4px;
display: flex;
align-items: center;
gap: 6px;
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: 6px;
padding: 4px 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 2;
}
/* Hover bridge: keeps :hover on the wrap while the cursor crosses the
8px gap between wrap and floating chrome, so the menu doesn't fade out
on the way to it. */
.transclusionControls::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 8px;
}
.transclusionWrap:hover .transclusionControls,
.transclusionWrap:focus-within .transclusionControls,
.transclusionWrap[data-menu-open="true"] .transclusionControls {
opacity: 1;
pointer-events: auto;
}
.controlsDivider {
display: inline-block;
width: 1px;
height: 16px;
background: var(--mantine-color-default-border);
}
.transclusionControls a[href],
.includeControls a[href] {
color: var(--ai-color);
border-bottom: none;
font-weight: inherit;
}
.includeWrap {
position: relative;
margin-left: -3rem;
margin-right: -3rem;
width: calc(100% + 6rem);
padding: 0.5em 0;
border-radius: 8px;
border: 2px solid transparent;
transition: border 0.3s;
}
.includeWrap:hover,
.includeWrap[data-focused="true"],
.includeWrap[data-menu-open="true"] {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
color-mix(in srgb, var(--mantine-color-orange-9), transparent 55%)
);
}
.includeControls {
position: absolute;
bottom: calc(100% + 8px);
right: 4px;
display: flex;
align-items: center;
gap: 6px;
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: 6px;
padding: 4px 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 2;
}
/* Hover bridge: keeps :hover on the wrap while the cursor crosses the
8px gap between wrap and floating chrome, so the menu doesn't fade out
on the way to it. */
.includeControls::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 8px;
}
.includeWrap:hover .includeControls,
.includeWrap:focus-within .includeControls,
.includeWrap[data-focused="true"] .includeControls,
.includeWrap[data-menu-open="true"] .includeControls {
opacity: 1;
pointer-events: auto;
}
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
outline: none;
}
@media (max-width: 48em) {
.transclusionWrap,
.includeWrap {
margin-left: -1rem;
margin-right: -1rem;
width: calc(100% + 2rem);
}
.transclusionWrap {
padding-left: 1rem;
padding-right: 1rem;
}
}
@media print {
.transclusionControls,
.includeControls {
display: none !important;
}
.transclusionWrap,
.includeWrap {
border: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
width: 100% !important;
padding: 0 !important;
}
}
.editingOriginalTag {
display: inline-block;
padding: 0 6px;
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: var(--mantine-color-blue-7);
background: light-dark(
var(--mantine-color-blue-0),
var(--mantine-color-blue-9)
);
border-radius: var(--mantine-radius-sm);
}
@@ -47,6 +47,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={getFileUrl(src)} src={getFileUrl(src)}
aria-label={placeholder?.name || t("Video")}
/> />
)} )}
{!src && previewSrc && ( {!src && previewSrc && (
@@ -56,6 +57,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={previewSrc} src={previewSrc}
aria-label={placeholder?.name || t("Video")}
/> />
<Loader size={20} pos="absolute" top={6} right={6} /> <Loader size={20} pos="absolute" top={6} right={6} />
</Group> </Group>
@@ -71,7 +73,7 @@ export default function VideoView(props: NodeViewProps) {
</Group> </Group>
)} )}
{!src && !previewSrc && !placeholder && ( {!src && !previewSrc && !placeholder && (
<video className={classes.video} controls /> <video className={classes.video} controls aria-label={t("Video")} />
)} )}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
@@ -0,0 +1,419 @@
import { Extension } from "@tiptap/core";
import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
} from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollThreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
/**
* Tags to be excluded for drag handle
*/
excludedTags: string[];
/**
* Custom nodes to be included for drag handle
*/
customNodes: string[];
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== "none") {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width,
};
}
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(
coords: { x: number; y: number },
options: GlobalDragHandleOptions,
view: EditorView,
) {
const selectors = [
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
...options.customNodes.map((node) => `[data-type=${node}]`),
].join(", ");
return document
.elementsFromPoint(coords.x, coords.y)
.find((elem: Element) => {
// Skip elements that belong to a nested editor (e.g. transclusion
// references render their own ProseMirror instance). Only consider
// elements whose closest editor is this host view.
if (elem.closest(".ProseMirror") !== view.dom) return false;
return (
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(selectors)
);
});
}
function nodePosAtDOM(
node: Element,
view: EditorView,
options: GlobalDragHandleOptions,
) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(
options: GlobalDragHandleOptions & { pluginKey: string },
) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
},
options,
view,
);
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
view.state.doc,
nodePos.before(),
);
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos &&
draggedNodePos <= nodeSelection.$to.pos
);
}
let selection = view.state.selection;
if (
!differentNodeSelected &&
diff !== 0 &&
!(view.state.selection instanceof NodeSelection)
) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
selection = TextSelection.create(
view.state.doc,
draggedNodePos,
endSelection.$to.pos,
);
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === "tableRow"
) {
let $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
}
}
view.dispatch(view.state.tr.setSelection(selection));
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem"
) {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = view.serializeForClipboard(slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hide");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hide");
}
}
function hideHandleOnEditorOut(event: MouseEvent) {
if (event.target instanceof Element) {
// Check if the relatedTarget class is still inside the editor
const relatedTarget = event.relatedTarget as HTMLElement;
const isInsideEditor =
relatedTarget?.classList.contains("tiptap") ||
relatedTarget?.classList.contains("drag-handle");
if (isInsideEditor) return;
}
hideDragHandle();
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
let scrollY = window.scrollY;
if (e.clientY < options.scrollThreshold) {
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold) {
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
}
}
dragHandleElement.addEventListener("drag", onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
view?.dom?.parentElement?.addEventListener(
"mouseout",
hideHandleOnEditorOut,
);
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
dragHandleElement?.removeEventListener(
"dragstart",
onDragHandleDragStart,
);
dragHandleElement = null;
view?.dom?.parentElement?.removeEventListener(
"mouseout",
hideHandleOnEditorOut,
);
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
},
options,
view,
);
const notDragging = node?.closest(".not-draggable");
const excludedTagList = options.excludedTags
.concat(["ol", "ul"])
.join(", ");
if (
!(node instanceof Element) ||
node.matches(excludedTagList) ||
notDragging
) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
? parseInt(compStyle.fontSize) * 1.2
: parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList =
resolvedPos.parent.type.name === "listItem";
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const newList = view.state.schema.nodes.orderedList?.createAndFill(
null,
droppedNode,
);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
const GlobalDragHandle = Extension.create({
name: "globalDragHandle",
addOptions() {
return {
dragHandleWidth: 20,
scrollThreshold: 100,
excludedTags: [],
customNodes: [],
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: "globalDragHandle",
dragHandleWidth: this.options.dragHandleWidth,
scrollThreshold: this.options.scrollThreshold,
dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags,
customNodes: this.options.customNodes,
}),
];
},
});
export default GlobalDragHandle;
@@ -9,7 +9,6 @@ import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube"; import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command"; import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command";
import renderItems from "@/features/editor/components/slash-menu/render-items"; import renderItems from "@/features/editor/components/slash-menu/render-items";
@@ -52,6 +51,8 @@ import {
Columns, Columns,
Column, Column,
Status, Status,
TransclusionSource,
TransclusionReference,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -80,6 +81,8 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { common, createLowlight } from "lowlight"; import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
@@ -100,6 +103,7 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts"; import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -167,7 +171,7 @@ export const mainExtensions = [
SharedStorage, SharedStorage,
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ["heading", "paragraph"], types: ["heading", "paragraph", "transclusionSource"],
filterTransaction: (transaction) => !isChangeOrigin(transaction), filterTransaction: (transaction) => !isChangeOrigin(transaction),
}), }),
Placeholder.configure({ Placeholder.configure({
@@ -215,7 +219,9 @@ export const mainExtensions = [
}), }),
Typography, Typography,
TrailingNode, TrailingNode,
GlobalDragHandle, GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference"],
}),
TextStyle, TextStyle,
Color, Color,
SlashCommand, SlashCommand,
@@ -351,6 +357,12 @@ export const mainExtensions = [
Status.configure({ Status.configure({
view: StatusView, view: StatusView,
}), }),
TransclusionSource.configure({
view: TransclusionView,
}),
TransclusionReference.configure({
view: TransclusionReferenceView,
}),
MarkdownClipboard.configure({ MarkdownClipboard.configure({
transformPastedText: true, transformPastedText: true,
}), }),
@@ -80,10 +80,12 @@ export const MarkdownClipboard = Extension.create({
const { from, to } = view.state.selection; const { from, to } = view.state.selection;
const parsed = markdownToHtml(text.replace(/\n+$/, "")); const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const body = elementFromString(parsed);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema( const contentNodes = DOMParser.fromSchema(
this.editor.schema, this.editor.schema,
).parseSlice(elementFromString(parsed), { ).parseSlice(body, {
preserveWhitespace: true, preserveWhitespace: true,
}); });
@@ -137,3 +139,92 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body; return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
} }
const DEFAULT_PASTE_COL_WIDTH_PX = 150;
function parsePixelWidth(el: Element): number | null {
const attr = el.getAttribute("width");
if (attr) {
const n = parseInt(attr, 10);
if (Number.isFinite(n) && n > 0) return n;
}
const style = el.getAttribute("style") || "";
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
function getFirstRow(table: Element): Element | null {
const tbodyRow = table.querySelector(":scope > tbody > tr");
if (tbodyRow) return tbodyRow;
const theadRow = table.querySelector(":scope > thead > tr");
if (theadRow) return theadRow;
return table.querySelector(":scope > tr");
}
function deriveColumnWidths(table: Element): (number | null)[] | null {
const cols = table.querySelectorAll(":scope > colgroup > col");
if (cols.length > 0) {
const widths: (number | null)[] = [];
cols.forEach((col) => widths.push(parsePixelWidth(col)));
if (widths.some((w) => w !== null)) return widths;
}
const firstRow = getFirstRow(table);
if (!firstRow) return null;
const widths: (number | null)[] = [];
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
const w = parsePixelWidth(cell);
for (let i = 0; i < colspan; i++) {
widths.push(w !== null ? Math.round(w / colspan) : null);
}
});
if (widths.length === 0 || widths.every((w) => w === null)) return null;
return widths;
}
// Mirror of server normalizeTableColumnWidths (see import/utils/table-utils.ts):
// markdown source has no widths, so without this every pasted table renders
// at table-layout:fixed/100% and squashes columns to fit the editor instead of
// letting .tableWrapper's overflow-x: auto scroll.
export function normalizeTableColumnWidths(root: Element): void {
root.querySelectorAll("table").forEach((table) => {
const firstRow = getFirstRow(table);
if (!firstRow) return;
let colWidths = deriveColumnWidths(table);
if (!colWidths) {
let count = 0;
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
count += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
});
if (count === 0) return;
colWidths = new Array(count).fill(DEFAULT_PASTE_COL_WIDTH_PX);
}
let col = 0;
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
if (cell.getAttribute("colwidth")) {
col += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
return;
}
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
const slice = colWidths!.slice(col, col + colspan);
col += colspan;
if (slice.length === 0 || slice.every((w) => w === null)) return;
const values = slice.map((w) => (w == null ? 100 : w));
cell.setAttribute("colwidth", values.join(","));
});
});
}
+63 -49
View File
@@ -62,7 +62,7 @@ import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
@@ -71,6 +71,7 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -232,11 +233,19 @@ export default function PageEditor({
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { if (event.key === "Tab") {
const editor = editorRef.current;
if (!editor) return false;
event.preventDefault();
return editor.view.someProp("handleKeyDown", (f) =>
f(editor.view, event)
);
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@@ -391,55 +400,60 @@ export default function PageEditor({
} }
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return ( return (
<div className="editor-container" style={{ position: "relative" }}> <TransclusionLookupProvider>
<div ref={menuContainerRef}> {showStatic ? (
<EditorContent editor={editor} /> <EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && ( {editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && editorIsEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorAiMenu editor={editor} /> <EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} /> <EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} /> <TableCellMenu editor={editor} appendTo={menuContainerRef} />
<ImageMenu editor={editor} /> <ImageMenu editor={editor} />
<VideoMenu editor={editor} /> <VideoMenu editor={editor} />
<PdfMenu editor={editor} /> <PdfMenu editor={editor} />
<CalloutMenu editor={editor} /> <CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} /> <SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} /> <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>
)} <div
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && ( onClick={() => editor.commands.focus("end")}
<ReadonlyBubbleMenu editor={editor} /> style={{ paddingBottom: "20vh" }}
)} ></div>
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} </div>
{showReadOnlyCommentPopup && ( )}
<CommentDialog editor={editor} pageId={pageId} readOnly /> </TransclusionLookupProvider>
)}
</div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
); );
} }
@@ -9,17 +9,26 @@ import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
content: any; content: any;
pageId?: string; pageId?: string;
/**
* When rendering inside a public share, pass the share's id (or key). Lookups
* for transclusion content then resolve against the share graph instead of
* the viewer's personal permissions, so a share never leaks source content
* that isn't itself shared.
*/
shareId?: string;
} }
export default function ReadonlyPageEditor({ export default function ReadonlyPageEditor({
title, title,
content, content,
pageId, pageId,
shareId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
@@ -65,7 +74,7 @@ export default function ReadonlyPageEditor({
]; ];
return ( return (
<> <TransclusionLookupProvider shareId={shareId}>
<div className="page-title"> <div className="page-title">
<EditorProvider <EditorProvider
editable={false} editable={false}
@@ -95,6 +104,6 @@ export default function ReadonlyPageEditor({
}} }}
></EditorProvider> ></EditorProvider>
<div style={{ paddingBottom: "20vh" }}></div> <div style={{ paddingBottom: "20vh" }}></div>
</> </TransclusionLookupProvider>
); );
} }
@@ -27,6 +27,7 @@ import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@@ -90,11 +91,11 @@ export function TitleEditor({
editorProps: { editorProps: {
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@@ -53,15 +53,17 @@ export default function StarButton(props: StarButtonProps) {
} }
}; };
const label = isFavorited
? t("Remove from favorites")
: t("Add to favorites");
return ( return (
<Tooltip <Tooltip label={label} openDelay={250} withArrow>
label={isFavorited ? t("Remove from favorites") : t("Add to favorites")}
openDelay={250}
withArrow
>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={isFavorited ? "yellow" : "gray"} color={isFavorited ? "yellow" : "gray"}
aria-label={label}
aria-pressed={isFavorited}
onClick={handleToggle} onClick={handleToggle}
loading={isPending} loading={isPending}
> >
@@ -53,7 +53,7 @@ export default function GroupActionMenu() {
arrowPosition="center" arrowPosition="center"
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="light"> <ActionIcon variant="light" aria-label={t("Group menu")}>
<IconDots size={20} stroke={2} /> <IconDots size={20} stroke={2} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -54,7 +54,7 @@ export default function GroupMembersList() {
<Table.Tr> <Table.Tr>
<Table.Th>{t("User")}</Table.Th> <Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th> <Table.Th>{t("Status")}</Table.Th>
<Table.Th></Table.Th> <Table.Th aria-label={t("Action")} />
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -4,7 +4,7 @@ import {
UnstyledButton, UnstyledButton,
Badge, Badge,
Table, Table,
ActionIcon, ThemeIcon,
Button, Button,
} from "@mantine/core"; } from "@mantine/core";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -61,13 +61,13 @@ export default function CreatedByMe({ spaceId }: Props) {
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ActionIcon <ThemeIcon
variant="transparent" variant="transparent"
color="gray" color="gray"
size={18} size={18}
> >
<IconFileDescription size={18} /> <IconFileDescription size={18} />
</ActionIcon> </ThemeIcon>
)} )}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")} {page.title || t("Untitled")}

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