Compare commits

..

35 Commits

Author SHA1 Message Date
f040f56b22 New translations translation.json (Korean) 2025-10-24 01:52:31 +01:00
70bc16316d New translations translation.json (Korean) 2025-10-24 00:55:27 +01:00
f81c0d0f3a New translations translation.json (Spanish) 2025-10-22 09:59:51 +01:00
cf7a439d74 New translations translation.json (Russian) 2025-10-11 16:52:33 +01:00
0d2503a329 New translations translation.json (Ukrainian) 2025-10-10 17:48:44 +01:00
c8d19cc23d New translations translation.json (Ukrainian) 2025-10-10 15:39:11 +01:00
605272e952 New translations translation.json (Russian) 2025-10-09 08:03:43 +01:00
70a51cb70d New translations translation.json (Portuguese, Brazilian) 2025-10-07 21:19:10 +01:00
cc1743e9fc New translations translation.json (English) 2025-10-07 21:19:09 +01:00
7609c89b0f New translations translation.json (Chinese Simplified) 2025-10-07 21:19:07 +01:00
45e199d319 New translations translation.json (Ukrainian) 2025-10-07 21:19:06 +01:00
258063004c New translations translation.json (Russian) 2025-10-07 21:19:05 +01:00
37e418b408 New translations translation.json (Dutch) 2025-10-07 21:19:04 +01:00
cfa9800720 New translations translation.json (Korean) 2025-10-07 21:19:02 +01:00
5dc0f01a22 New translations translation.json (Japanese) 2025-10-07 21:19:01 +01:00
00aaa5849a New translations translation.json (Italian) 2025-10-07 21:19:00 +01:00
be6f72e48e New translations translation.json (Spanish) 2025-10-07 21:18:59 +01:00
eaa00ae9ec New translations translation.json (French) 2025-10-07 21:18:57 +01:00
29e86a2a81 New translations translation.json (German) 2025-10-07 21:18:56 +01:00
cea14282d0 New translations translation.json (German) 2025-09-25 14:01:28 +01:00
1e60899d05 New translations translation.json (German) 2025-09-24 15:20:25 +01:00
a494992625 New translations translation.json (Russian) 2025-09-18 07:57:18 +01:00
385ef63f4e New translations translation.json (Portuguese, Brazilian) 2025-09-15 21:39:44 +01:00
0505cac352 New translations translation.json (English) 2025-09-15 21:39:43 +01:00
559d5074cb New translations translation.json (Chinese Simplified) 2025-09-15 21:39:41 +01:00
82d12a2406 New translations translation.json (Ukrainian) 2025-09-15 21:39:40 +01:00
389d747eb7 New translations translation.json (Russian) 2025-09-15 21:39:39 +01:00
864e01ac28 New translations translation.json (Dutch) 2025-09-15 21:39:38 +01:00
d1efa37e1b New translations translation.json (Korean) 2025-09-15 21:39:37 +01:00
44407d8e08 New translations translation.json (Japanese) 2025-09-15 21:39:36 +01:00
4e1df3431c New translations translation.json (Italian) 2025-09-15 21:39:35 +01:00
683497a8d7 New translations translation.json (German) 2025-09-15 21:39:34 +01:00
9a3ef526f2 New translations translation.json (Spanish) 2025-09-15 21:39:33 +01:00
2b0a483fec New translations translation.json (French) 2025-09-15 21:39:32 +01:00
e05966a702 New translations translation.json (Dutch) 2025-09-08 11:47:52 +01:00
136 changed files with 1159 additions and 3765 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.23.2", "version": "0.23.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@ -17,7 +17,6 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3", "@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^8.1.3",

View File

@ -42,7 +42,7 @@
"Delete group": "Gruppe löschen", "Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Einzelheiten", "Details": "Details",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.", "e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler", "e.g Developers": "z.B. Entwickler",
@ -527,5 +527,32 @@
"Delete SSO provider": "SSO-Anbieter löschen", "Delete SSO provider": "SSO-Anbieter löschen",
"Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?", "Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?",
"Action": "Aktion", "Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration" "{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration",
"Icon": "Icon",
"Upload image": "Bild hochladen",
"Remove image": "Bild entfernen",
"Failed to remove image": "Fehler beim Entfernen des Bildes",
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "Eliminar proveedor de SSO", "Delete SSO provider": "Eliminar proveedor de SSO",
"Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?", "Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?",
"Action": "Acción", "Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}",
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "Supprimer le fournisseur SSO", "Delete SSO provider": "Supprimer le fournisseur SSO",
"Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?", "Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?",
"Action": "Action", "Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "Elimina provider SSO", "Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?", "Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione", "Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "SSOプロバイダーを削除する", "Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか", "Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか",
"Action": "アクション", "Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成" "{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "SSO 제공자 삭제", "Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?", "Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업", "Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성" "{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성",
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리"
} }

View File

@ -34,7 +34,7 @@
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken", "Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken", "Create space": "Ruimte aanmaken",
"Create workspace": "Wwerkruimte aanmaken", "Create workspace": "Werkruimte aanmaken",
"Current password": "Huidig wachtwoord", "Current password": "Huidig wachtwoord",
"Dark": "Donker", "Dark": "Donker",
"Date": "Datum", "Date": "Datum",
@ -91,7 +91,7 @@
"Invite by email": "Uitnodigen via e-mail", "Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen", "Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen", "Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.", "Invited members who are yet to accept their invitation will appear here.": "Uitgenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft", "Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte", "Join the workspace": "Word lid van de werkruimte",
"Language": "Taal", "Language": "Taal",
@ -527,5 +527,32 @@
"Delete SSO provider": "Verwijder SSO-provider", "Delete SSO provider": "Verwijder SSO-provider",
"Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?", "Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?",
"Action": "Actie", "Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie" "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "Excluir provedor de SSO", "Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?", "Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação", "Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -498,10 +498,10 @@
"Deleted at": "Удалено в", "Deleted at": "Удалено в",
"Preview": "Предпросмотр", "Preview": "Предпросмотр",
"Subpages": "Подстраницы", "Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить подстраницы", "Failed to load subpages": "Не удалось загрузить под страницы",
"No subpages": "Нет подстраниц", "No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)", "Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все подстраницы текущей страницы", "List all subpages of the current page": "Показать все под страницы",
"Attachments": "Вложения", "Attachments": "Вложения",
"All spaces": "Все пространства", "All spaces": "Все пространства",
"Unknown": "Неизвестно", "Unknown": "Неизвестно",
@ -527,5 +527,32 @@
"Delete SSO provider": "Удалить поставщика SSO", "Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?", "Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие", "Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}",
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "Видалити постачальника SSO", "Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?", "Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія", "Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "删除SSO提供商", "Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗", "Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗",
"Action": "操作", "Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置" "{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
} }

View File

@ -35,8 +35,6 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -98,10 +96,8 @@ export default function App() {
path={"account/preferences"} path={"account/preferences"}
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />

View File

@ -1,165 +0,0 @@
import React, { useRef } from "react";
import { Menu, Box, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconTrash, IconUpload } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { notifications } from "@mantine/notifications";
interface AvatarUploaderProps {
currentImageUrl?: string | null;
fallbackName?: string;
radius?: string | number;
size?: string | number;
variant?: string;
type: AvatarIconType;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
isLoading?: boolean;
disabled?: boolean;
}
export default function AvatarUploader({
currentImageUrl,
fallbackName,
radius,
variant,
size,
type,
onUpload,
onRemove,
isLoading = false,
disabled = false,
}: AvatarUploaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file || disabled) {
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
notifications.show({
message: t("Image exceeds 10MB limit."),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
await onUpload(file);
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to upload image"),
color: "red",
});
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
console.error("File input ref is null!");
}
};
const handleRemove = async () => {
if (disabled) return;
try {
await onRemove();
notifications.show({
message: t("Image removed successfully"),
});
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to remove image"),
color: "red",
});
}
};
return (
<Box>
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
style={{ display: "none" }}
/>
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
<Menu.Target>
<Box style={{ position: "relative", display: "inline-block" }}>
<CustomAvatar
component="button"
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
radius={radius}
variant={variant}
type={type}
/>
{isLoading && (
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
}}
>
<Loader size="sm" />
</Box>
)}
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUpload size={16} />}
disabled={isLoading || disabled}
onClick={handleUploadClick}
>
{t("Upload image")}
</Menu.Item>
{currentImageUrl && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={handleRemove}
disabled={isLoading || disabled}
>
{t("Remove image")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}

View File

@ -4,15 +4,14 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps { interface NoTableResultsProps {
colSpan: number; colSpan: number;
text?: string;
} }
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) { export default function NoTableResults({ colSpan }: NoTableResultsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={colSpan}> <Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center"> <Text fw={500} c="dimmed" ta="center">
{text || t("No results found...")} {t("No results found...")}
</Text> </Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>

View File

@ -1,8 +1,8 @@
import { import {
Group, Group,
Menu, Menu,
Text,
UnstyledButton, UnstyledButton,
Text,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@ -10,6 +10,7 @@ import {
IconBrush, IconBrush,
IconCheck, IconCheck,
IconChevronDown, IconChevronDown,
IconChevronRight,
IconDeviceDesktop, IconDeviceDesktop,
IconLogout, IconLogout,
IconMoon, IconMoon,
@ -25,7 +26,6 @@ import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts"; import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function TopMenu() { export default function TopMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -50,7 +50,6 @@ export default function TopMenu() {
name={workspace?.name} name={workspace?.name}
variant="filled" variant="filled"
size="sm" size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/> />
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}> <Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name} {workspace?.name}

View File

@ -10,7 +10,6 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts"; import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -66,17 +65,3 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }), queryFn: () => getShares({ page: 1, limit: 100 }),
}); });
}; };
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
});
};

View File

@ -21,8 +21,6 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
@ -62,14 +60,6 @@ const groupedData: DataGroup[] = [
icon: IconBrush, icon: IconBrush,
path: "/settings/account/preferences", path: "/settings/account/preferences",
}, },
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
], ],
}, },
{ {
@ -100,15 +90,6 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
], ],
}, },
{ {
@ -214,12 +195,6 @@ export default function SettingsSidebar() {
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
break; break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
default: default:
break; break;
} }

View File

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { Avatar } from "@mantine/core"; import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts"; import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps { interface CustomAvatarProps {
avatarUrl: string; avatarUrl: string;
@ -12,15 +11,13 @@ interface CustomAvatarProps {
variant?: string; variant?: string;
style?: any; style?: any;
component?: any; component?: any;
type?: AvatarIconType;
mt?: string | number;
} }
export const CustomAvatar = React.forwardRef< export const CustomAvatar = React.forwardRef<
HTMLInputElement, HTMLInputElement,
CustomAvatarProps CustomAvatarProps
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => { >(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type); const avatarLink = getAvatarUrl(avatarUrl);
return ( return (
<Avatar <Avatar

View File

@ -1,72 +0,0 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import CopyTextButton from "@/components/common/copy.tsx";
interface ApiKeyCreatedModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey;
}
export function ApiKeyCreatedModal({
opened,
onClose,
apiKey,
}: ApiKeyCreatedModalProps) {
const { t } = useTranslation();
if (!apiKey) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("API key")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{
flex: 1,
}}
value={apiKey.token}
readOnly
/>
<CopyTextButton text={apiKey.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
);
}

View File

@ -1,143 +0,0 @@
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 { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
isLoading?: boolean;
showUserColumn?: boolean;
onUpdate?: (apiKey: IApiKey) => void;
onRevoke?: (apiKey: IApiKey) => void;
}
export function ApiKeyTable({
apiKeys,
isLoading,
showUserColumn = false,
onUpdate,
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys && apiKeys.length > 0 ? (
apiKeys.map((apiKey: IApiKey, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Text fz="sm" fw={500}>
{apiKey.name}
</Text>
</Table.Td>
{showUserColumn && apiKey.creator && (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={apiKey.creator?.avatarUrl}
name={apiKey.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{apiKey.creator.name}
</Text>
</Group>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
isExpired(apiKey.expiresAt) ? (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Expired")}
</Text>
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.expiresAt)}
</Text>
)
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.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(apiKey)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(apiKey)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -1,153 +0,0 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
import { IApiKey } from "@/ee/api-key";
const DateInput = lazy(() =>
import("@mantine/dates").then((module) => ({
default: module.DateInput,
})),
);
interface CreateApiKeyModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IApiKey) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateApiKeyModal({
opened,
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
},
});
const getExpirationDate = (): string | undefined => {
if (expirationOption === "never") {
return undefined;
}
if (expirationOption === "custom") {
return form.values.expiresAt;
}
const days = parseInt(expirationOption);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
};
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
return `${days} days (${formatted})`;
};
const expirationOptions = [
{ value: "30", label: getExpirationLabel(30) },
{ value: "60", label: getExpirationLabel(60) },
{ value: "90", label: getExpirationLabel(90) },
{ value: "365", label: getExpirationLabel(365) },
{ value: "custom", label: t("Custom") },
{ value: "never", label: t("No expiration") },
];
const handleSubmit = async (data: {
name?: string;
expiresAt?: string | Date;
}) => {
const groupData = {
name: data.name,
expiresAt: getExpirationDate(),
};
try {
const createdKey = await createApiKeyMutation.mutateAsync(groupData);
onSuccess(createdKey);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
setExpirationOption("30");
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
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")}
/>
<Select
label={t("Expiration")}
data={expirationOptions}
value={expirationOption}
onChange={(value) => setExpirationOption(value || "30")}
leftSection={<IconCalendar size={16} />}
allowDeselect={false}
/>
{expirationOption === "custom" && (
<Suspense fallback={null}>
<DateInput
label={t("Custom expiration date")}
placeholder={t("Select expiration date")}
minDate={new Date()}
{...form.getInputProps("expiresAt")}
/>
</Suspense>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createApiKeyMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -1,62 +0,0 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
interface RevokeApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function RevokeApiKeyModal({
opened,
onClose,
apiKey,
}: RevokeApiKeyModalProps) {
const { t } = useTranslation();
const revokeApiKeyMutation = useRevokeApiKeyMutation();
const handleRevoke = async () => {
if (!apiKey) return;
await revokeApiKeyMutation.mutateAsync({
apiKeyId: apiKey.id,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Any applications using this API key will stop working.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeApiKeyMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -1,80 +0,0 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
import { useEffect } from "react";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function UpdateApiKeyModal({
opened,
onClose,
apiKey,
}: UpdateApiKeyModalProps) {
const { t } = useTranslation();
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
},
});
useEffect(() => {
if (opened && apiKey) {
form.setValues({ name: apiKey.name });
}
}, [opened, apiKey]);
const handleSubmit = async (data: { name?: string }) => {
const apiKeyData = {
apiKeyId: apiKey.id,
name: data.name,
};
await updateApiKeyMutation.mutateAsync(apiKeyData);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive token name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateApiKeyMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -1,11 +0,0 @@
export { ApiKeyTable } from "./components/api-key-table";
export { CreateApiKeyModal } from "./components/create-api-key-modal";
export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
export { UpdateApiKeyModal } from "./components/update-api-key-modal";
export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
// Services
export * from "./services/api-key-service";
// Types
export * from "./types/api-key.types";

View File

@ -1,106 +0,0 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -1,117 +0,0 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items}
isLoading={isLoading}
showUserColumn
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -1,97 +0,0 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createApiKey,
getApiKeys,
IApiKey,
ICreateApiKeyRequest,
IUpdateApiKeyRequest,
revokeApiKey,
updateApiKey,
} from "@/ee/api-key";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetApiKeysQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IApiKey>, Error> {
return useQuery({
queryKey: ["api-key-list", params],
queryFn: () => getApiKeys(params),
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useRevokeApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
Error,
{
apiKeyId: string;
}
>({
mutationFn: (data) => revokeApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, IUpdateApiKeyRequest>({
mutationFn: (data) => updateApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -1,32 +0,0 @@
import api from "@/lib/api-client";
import {
ICreateApiKeyRequest,
IApiKey,
IUpdateApiKeyRequest,
} from "@/ee/api-key/types/api-key.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getApiKeys(
params?: QueryParams,
): Promise<IPagination<IApiKey>> {
const req = await api.post("/api-keys", { ...params });
return req.data;
}
export async function createApiKey(
data: ICreateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/create", data);
return req.data;
}
export async function updateApiKey(
data: IUpdateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/update", data);
return req.data;
}
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
await api.post("/api-keys/revoke", data);
}

View File

@ -1,23 +0,0 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IApiKey {
id: string;
name: string;
token?: string;
creatorId: string;
workspaceId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
creator: Partial<IUser>;
}
export interface ICreateApiKeyRequest {
name: string;
expiresAt?: string;
}
export interface IUpdateApiKeyRequest {
apiKeyId: string;
name: string;
}

View File

@ -1,64 +0,0 @@
import api from "@/lib/api-client";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
return await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.AVATAR);
}
export async function uploadSpaceIcon(
file: File,
spaceId: string,
): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
}
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
}
async function removeIcon(
type: AvatarIconType,
spaceId?: string,
): Promise<void> {
const payload: { spaceId?: string; type: string } = { type };
if (spaceId) {
payload.spaceId = spaceId;
}
await api.post("/attachments/remove-icon", payload);
}
export async function removeAvatar(): Promise<void> {
await removeIcon(AvatarIconType.AVATAR);
}
export async function removeSpaceIcon(spaceId: string): Promise<void> {
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
}
export async function removeWorkspaceIcon(): Promise<void> {
await removeIcon(AvatarIconType.WORKSPACE_ICON);
}

View File

@ -1,9 +0,0 @@
export {
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
uploadWorkspaceIcon,
removeAvatar,
removeSpaceIcon,
removeWorkspaceIcon,
} from "./attachment-service.ts";

View File

@ -1,29 +0,0 @@
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
WORKSPACE_ICON = "workspace-icon",
}
export enum AttachmentType {
AVATAR = "avatar",
WORKSPACE_ICON = "workspace-icon",
SPACE_ICON = "space-icon",
FILE = "file",
}

View File

@ -3,7 +3,6 @@ import {
BubbleMenuProps, BubbleMenuProps,
isNodeSelection, isNodeSelection,
useEditor, useEditor,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
@ -51,52 +50,34 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
if (!props.editor) {
return null;
}
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Bold", name: "Bold",
isActive: () => editorState?.isBold, isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(), command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold, icon: IconBold,
}, },
{ {
name: "Italic", name: "Italic",
isActive: () => editorState?.isItalic, isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(), command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic, icon: IconItalic,
}, },
{ {
name: "Underline", name: "Underline",
isActive: () => editorState?.isUnderline, isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(), command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline, icon: IconUnderline,
}, },
{ {
name: "Strike", name: "Strike",
isActive: () => editorState?.isStrike, isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(), command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough, icon: IconStrikethrough,
}, },
{ {
name: "Code", name: "Code",
isActive: () => editorState?.isCode, isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(), command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode, icon: IconCode,
}, },
@ -104,7 +85,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = { const commentItem: BubbleMenuItem = {
name: "Comment", name: "Comment",
isActive: () => editorState?.isComment, isActive: () => props.editor.isActive("comment"),
command: () => { command: () => {
const commentId = uuid7(); const commentId = uuid7();

View File

@ -9,8 +9,7 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
@ -19,7 +18,7 @@ export interface BubbleColorMenuItem {
} }
interface ColorSelectorProps { interface ColorSelectorProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@ -109,36 +108,12 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen, setIsOpen,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: ctx => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
});
HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
});
return activeColors;
},
});
if (!editor || !editorState) {
return null;
}
const activeColorItem = TEXT_COLORS.find(({ color }) => const activeColorItem = TEXT_COLORS.find(({ color }) =>
editorState[`text_${color}`] editor.isActive("textStyle", { color }),
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editorState[`highlight_${color}`] editor.isActive("highlight", { color }),
); );
return ( return (
@ -176,7 +151,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
justify="left" justify="left"
fullWidth fullWidth
rightSection={ rightSection={
editorState[`text_${color}`] && ( editor.isActive("textStyle", { color }) && (
<IconCheck style={{ width: rem(16) }} /> <IconCheck style={{ width: rem(16) }} />
) )
} }

View File

@ -13,12 +13,11 @@ import {
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core"; import { Popover, Button, ScrollArea } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@ -37,27 +36,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!editor) {
return null;
}
return {
isParagraph: ctx.editor.isActive("paragraph"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Text", name: "Text",
@ -65,45 +43,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(), editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => isActive: () =>
editorState?.isParagraph && editor.isActive("paragraph") &&
!editorState?.isBulletList && !editor.isActive("bulletList") &&
!editorState?.isOrderedList, !editor.isActive("orderedList"),
}, },
{ {
name: "Heading 1", name: "Heading 1",
icon: IconH1, icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editorState?.isHeading1, isActive: () => editor.isActive("heading", { level: 1 }),
}, },
{ {
name: "Heading 2", name: "Heading 2",
icon: IconH2, icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editorState?.isHeading2, isActive: () => editor.isActive("heading", { level: 2 }),
}, },
{ {
name: "Heading 3", name: "Heading 3",
icon: IconH3, icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editorState?.isHeading3, isActive: () => editor.isActive("heading", { level: 3 }),
}, },
{ {
name: "To-do List", name: "To-do List",
icon: IconCheckbox, icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(), command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editorState?.isTaskItem, isActive: () => editor.isActive("taskItem"),
}, },
{ {
name: "Bullet List", name: "Bullet List",
icon: IconList, icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(), command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editorState?.isBulletList, isActive: () => editor.isActive("bulletList"),
}, },
{ {
name: "Numbered List", name: "Numbered List",
icon: IconListNumbers, icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(), command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editorState?.isOrderedList, isActive: () => editor.isActive("orderedList"),
}, },
{ {
name: "Blockquote", name: "Blockquote",
@ -115,13 +93,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph") .toggleNode("paragraph", "paragraph")
.toggleBlockquote() .toggleBlockquote()
.run(), .run(),
isActive: () => editorState?.isBlockquote, isActive: () => editor.isActive("blockquote"),
}, },
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editorState?.isCodeBlock, isActive: () => editor.isActive("codeBlock"),
}, },
]; ];

View File

@ -8,12 +8,11 @@ import {
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core"; import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TextAlignmentProps { interface TextAlignmentProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@ -32,54 +31,36 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Align left", name: "Align left",
isActive: () => editorState?.isAlignLeft, isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
isActive: () => editorState?.isAlignCenter, isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
isActive: () => editorState?.isAlignRight, isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
{ {
name: "Justify", name: "Justify",
isActive: () => editorState?.isAlignJustify, isActive: () => editor.isActive({ textAlign: "justify" }),
command: () => editor.chain().focus().setTextAlign("justify").run(), command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified, icon: IconAlignJustified,
}, },
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0]; const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
@ -92,7 +73,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<activeItem.icon style={{ width: rem(16) }} stroke={2} /> <IconAlignLeft style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
</Popover.Target> </Popover.Target>

View File

@ -2,7 +2,6 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
@ -10,7 +9,7 @@ import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip, Divider } from "@mantine/core";
import { import {
IconAlertTriangleFilled, IconAlertTriangleFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
@ -36,23 +35,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
@ -110,7 +92,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@ -129,7 +111,9 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")} onClick={() => setCalloutType("info")}
size="lg" size="lg"
aria-label={t("Info")} aria-label={t("Info")}
variant={editorState?.isInfo ? "light" : "default"} variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
> >
<IconInfoCircleFilled size={18} /> <IconInfoCircleFilled size={18} />
</ActionIcon> </ActionIcon>
@ -140,7 +124,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")} onClick={() => setCalloutType("success")}
size="lg" size="lg"
aria-label={t("Success")} aria-label={t("Success")}
variant={editorState?.isSuccess ? "light" : "default"} variant={
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
> >
<IconCircleCheckFilled size={18} /> <IconCircleCheckFilled size={18} />
</ActionIcon> </ActionIcon>
@ -151,7 +139,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")} onClick={() => setCalloutType("warning")}
size="lg" size="lg"
aria-label={t("Warning")} aria-label={t("Warning")}
variant={editorState?.isWarning ? "light" : "default"} variant={
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
> >
<IconAlertTriangleFilled size={18} /> <IconAlertTriangleFilled size={18} />
</ActionIcon> </ActionIcon>
@ -162,7 +154,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")} onClick={() => setCalloutType("danger")}
size="lg" size="lg"
aria-label={t("Danger")} aria-label={t("Danger")}
variant={editorState?.isDanger ? "light" : "default"} variant={
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
> >
<IconCircleXFilled size={18} /> <IconCircleXFilled size={18} />
</ActionIcon> </ActionIcon>

View File

@ -2,16 +2,15 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState, } from '@tiptap/react';
} from "@tiptap/react"; import { useCallback } from 'react';
import { useCallback } from "react"; import { sticky } from 'tippy.js';
import { sticky } from "tippy.js"; import { Node as PMNode } from 'prosemirror-model';
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@ -20,29 +19,14 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
}, },
[editor], [editor]
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === 'drawio';
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@ -55,37 +39,40 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes("drawio", { width: `${value}%` }); editor.commands.updateAttributes('drawio', { width: `${value}%` });
}, },
[editor], [editor]
); );
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: "flip", enabled: false }], modifiers: [{ name: 'flip', enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: "popper", sticky: 'popper',
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
}} }}
> >
{editorState?.width && ( {editor.getAttributes('drawio')?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>

View File

@ -17,7 +17,7 @@ import {
EventExit, EventExit,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/lib/types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";

View File

@ -2,16 +2,15 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState, } from '@tiptap/react';
} from "@tiptap/react"; import { useCallback } from 'react';
import { useCallback } from "react"; import { sticky } from 'tippy.js';
import { sticky } from "tippy.js"; import { Node as PMNode } from 'prosemirror-model';
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@ -20,31 +19,14 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return ( return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
);
}, },
[editor], [editor]
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === 'excalidraw';
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@ -57,9 +39,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes("excalidraw", { width: `${value}%` }); editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
}, },
[editor], [editor]
); );
return ( return (
@ -72,22 +54,25 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: "flip", enabled: false }], modifiers: [{ name: 'flip', enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: "popper", sticky: 'popper',
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
}} }}
> >
{editorState?.width && ( {editor.getAttributes('excalidraw')?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>

View File

@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/lib/types";
import ReactClearModal from "react-clear-modal"; import ReactClearModal from "react-clear-modal";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";

View File

@ -2,7 +2,6 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@ -33,25 +32,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
isImage: ctx.editor.isActive("image"),
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
@ -103,7 +83,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@ -123,7 +103,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft} onClick={alignImageLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"} variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@ -134,7 +116,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter} onClick={alignImageCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"} variant={
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@ -145,15 +131,20 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight} onClick={alignImageRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"} variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editorState?.width && ( {editor.getAttributes("image")?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );

View File

@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@ -12,18 +12,7 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link"); return editor.isActive("link");
}, [editor]); }, [editor]);
const editorState = useEditorState({ const { href: link } = editor.getAttributes("link");
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
setShowEdit(true); setShowEdit(true);
@ -81,14 +70,11 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs" padding="xs"
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
> >
<LinkEditorPanel <LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card> </Card>
) : ( ) : (
<LinkPreviewPanel <LinkPreviewPanel
url={editorState?.href} url={link}
onClear={onUnsetLink} onClear={onUnsetLink}
onEdit={handleEdit} onEdit={handleEdit}
/> />

View File

@ -9,8 +9,7 @@ import {
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface TableColorItem { export interface TableColorItem {
@ -19,7 +18,7 @@ export interface TableColorItem {
} }
interface TableBackgroundColorProps { interface TableBackgroundColorProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
} }
const TABLE_COLORS: TableColorItem[] = [ const TABLE_COLORS: TableColorItem[] = [
@ -39,50 +38,37 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
let currentColor = "";
if (ctx.editor.isActive("tableCell")) {
const attrs = ctx.editor.getAttributes("tableCell");
currentColor = attrs.backgroundColor || "";
} else if (ctx.editor.isActive("tableHeader")) {
const attrs = ctx.editor.getAttributes("tableHeader");
currentColor = attrs.backgroundColor || "";
}
return {
currentColor,
isTableCell: ctx.editor.isActive("tableCell"),
isTableHeader: ctx.editor.isActive("tableHeader"),
};
},
});
if (!editor || !editorState) {
return null;
}
const setTableCellBackground = (color: string, colorName: string) => { const setTableCellBackground = (color: string, colorName: string) => {
editor editor
.chain() .chain()
.focus() .focus()
.updateAttributes("tableCell", { .updateAttributes("tableCell", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null, backgroundColorName: color ? colorName : null
}) })
.updateAttributes("tableHeader", { .updateAttributes("tableHeader", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null, backgroundColorName: color ? colorName : null
}) })
.run(); .run();
setOpened(false); setOpened(false);
}; };
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return ( return (
<Popover <Popover
width={200} width={200}
@ -137,7 +123,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
cursor: "pointer", cursor: "pointer",
}} }}
> >
{editorState.currentColor === item.color && ( {currentColor === item.color && (
<IconCheck <IconCheck
size={18} size={18}
style={{ style={{

View File

@ -9,15 +9,15 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Popover, Popover,
rem,
ScrollArea, ScrollArea,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps { interface TableTextAlignmentProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
} }
interface AlignmentItem { interface AlignmentItem {
@ -32,44 +32,25 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: AlignmentItem[] = [ const items: AlignmentItem[] = [
{ {
name: "Align left", name: "Align left",
value: "left", value: "left",
isActive: () => editorState?.isAlignLeft, isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
value: "center", value: "center",
isActive: () => editorState?.isAlignCenter, isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
value: "right", value: "right",
isActive: () => editorState?.isAlignRight, isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
@ -83,7 +64,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
onChange={setOpened} onChange={setOpened}
position="bottom" position="bottom"
withArrow withArrow
transitionProps={{ transition: "pop" }} transitionProps={{ transition: 'pop' }}
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text alignment")} withArrow> <Tooltip label={t("Text alignment")} withArrow>
@ -106,7 +87,9 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
key={index} key={index}
variant="default" variant="default"
leftSection={<item.icon size={16} />} leftSection={<item.icon size={16} />}
rightSection={item.isActive() && <IconCheck size={16} />} rightSection={
item.isActive() && <IconCheck size={16} />
}
justify="left" justify="left"
fullWidth fullWidth
onClick={() => { onClick={() => {

View File

@ -2,7 +2,6 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@ -33,25 +32,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
isVideo: ctx.editor.isActive("video"),
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
@ -103,7 +83,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@ -123,7 +103,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoLeft} onClick={alignVideoLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"} variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@ -134,7 +116,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter} onClick={alignVideoCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"} variant={
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@ -145,15 +131,20 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight} onClick={alignVideoRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"} variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editorState?.width && ( {editor.getAttributes("video")?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );

View File

@ -165,7 +165,7 @@ export const mainExtensions = [
}), }),
CustomTable.configure({ CustomTable.configure({
resizable: true, resizable: true,
lastColumnResizable: true, lastColumnResizable: false,
allowTableNodeSelection: true, allowTableNodeSelection: true,
}), }),
TableRow, TableRow,

View File

@ -7,12 +7,7 @@ import {
onAuthenticationFailedParameters, onAuthenticationFailedParameters,
WebSocketStatus, WebSocketStatus,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
EditorContent,
EditorProvider,
useEditor,
useEditorState,
} from "@tiptap/react";
import { import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
@ -55,7 +50,7 @@ import { extractPageSlugId } 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";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from '@/features/search/constants.ts';
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@ -82,7 +77,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`; const documentName = `page.${pageId}`;
@ -218,17 +213,17 @@ export default function PageEditor({
extensions, extensions,
editable, editable,
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: true,
editorProps: { editorProps: {
scrollThreshold: 80, scrollThreshold: 80,
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@ -273,16 +268,9 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider], [pageId, editable, remoteProvider]
); );
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
return ctx.editor?.isEditable ?? false;
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]); const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@ -318,7 +306,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent, handleActiveCommentEvent
); );
}; };
}, []); }, []);
@ -401,7 +389,7 @@ export default function PageEditor({
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && editorIsEditable && ( {editor && editor.isEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />

View File

@ -94,12 +94,8 @@
hr { hr {
border: none; border: none;
@mixin light { border-top: 2px solid #ced4da;
border-top: 1px solid var(--mantine-color-gray-4); margin: 2rem 0;
}
@mixin dark {
border-top: 1px solid var(--mantine-color-dark-4);
}
&:hover { &:hover {
cursor: pointer; cursor: pointer;

View File

@ -1,12 +1,11 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts"; import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
import { useForm } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod"; import * as z from "zod";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx"; import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(50),

View File

@ -4,11 +4,10 @@ import {
useGroupQuery, useGroupQuery,
useUpdateGroupMutation, useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts"; } from "@/features/group/queries/group-query.ts";
import { useForm } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod"; import * as z from "zod";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),

View File

@ -22,7 +22,6 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useTranslation } from 'react-i18next';
export function useGetGroupsQuery( export function useGetGroupsQuery(
params?: QueryParams, params?: QueryParams,
@ -74,12 +73,11 @@ export function useCreateGroupMutation() {
export function useUpdateGroupMutation() { export function useUpdateGroupMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IGroup, Error, Partial<IGroup>>({ return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data), mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Group updated successfully") }); notifications.show({ message: "Group updated successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["group", variables.groupId], queryKey: ["group", variables.groupId],
}); });
@ -93,12 +91,11 @@ export function useUpdateGroupMutation() {
export function useDeleteGroupMutation() { export function useDeleteGroupMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }), mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Group deleted successfully") }); notifications.show({ message: "Group deleted successfully" });
queryClient.refetchQueries({ queryKey: ["groups"] }); queryClient.refetchQueries({ queryKey: ["groups"] });
}, },
onError: (error) => { onError: (error) => {
@ -122,12 +119,11 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() { export function useAddGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({ return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data), mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Added successfully") }); notifications.show({ message: "Added successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });
@ -143,7 +139,6 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() { export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation< return useMutation<
void, void,
@ -155,7 +150,7 @@ export function useRemoveGroupMemberMutation() {
>({ >({
mutationFn: (data) => removeGroupMember(data), mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Removed successfully") }); notifications.show({ message: "Removed successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });

View File

@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils"; import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx"; import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts"; import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
@ -84,12 +84,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const [fileTaskId, setFileTaskId] = useState<string | null>(null); const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit(); const emit = useQueryEmit();
const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
@ -122,15 +116,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
}); });
setFileTaskId(importTask.id); setFileTaskId(importTask.id);
// Reset file input after successful upload
if (source === "notion" && notionFileRef.current) {
notionFileRef.current();
} else if (source === "confluence" && confluenceFileRef.current) {
confluenceFileRef.current();
} else if (source === "generic" && zipFileRef.current) {
zipFileRef.current();
}
} catch (err) { } catch (err) {
console.log("Failed to upload import file", err); console.log("Failed to upload import file", err);
notifications.update({ notifications.update({
@ -258,10 +243,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree); setTreeData(fullTree);
} }
// Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current();
const pageCountText = const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`; pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@ -291,7 +272,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return ( return (
<> <>
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}> <FileButton onChange={handleFileUpload} accept=".md" multiple>
{(props) => ( {(props) => (
<Button <Button
justify="start" justify="start"
@ -304,7 +285,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)} )}
</FileButton> </FileButton>
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}> <FileButton onChange={handleFileUpload} accept="text/html" multiple>
{(props) => ( {(props) => (
<Button <Button
justify="start" justify="start"
@ -320,7 +301,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton <FileButton
onChange={(file) => handleZipUpload(file, "notion")} onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip" accept="application/zip"
resetRef={notionFileRef}
> >
{(props) => ( {(props) => (
<Button <Button
@ -336,7 +316,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton <FileButton
onChange={(file) => handleZipUpload(file, "confluence")} onChange={(file) => handleZipUpload(file, "confluence")}
accept="application/zip" accept="application/zip"
resetRef={confluenceFileRef}
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
@ -373,7 +352,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton <FileButton
onChange={(file) => handleZipUpload(file, "generic")} onChange={(file) => handleZipUpload(file, "generic")}
accept="application/zip" accept="application/zip"
resetRef={zipFileRef}
> >
{(props) => ( {(props) => (
<Group justify="center"> <Group justify="center">

View File

@ -9,11 +9,10 @@ import {
SidebarPagesParams, SidebarPagesParams,
} from '@/features/page/types/page.types'; } from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/types"; import { QueryParams } from "@/lib/types";
import { IPagination } from "@/lib/types.ts"; import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query"; import { InfiniteData } from "@tanstack/react-query";
import { IFileTask } from '@/features/file-task/types/file-task.types.ts'; import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
export async function createPage(data: Partial<IPage>): Promise<IPage> { export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data); const req = await api.post<IPage>("/pages/create", data);

View File

@ -8,7 +8,6 @@ import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts"; import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useLicense } from "@/ee/hooks/use-license.tsx";
import { isCloud } from "@/lib/config.ts";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
@ -44,7 +43,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && (hasLicenseKey || isCloud()); filters.contentType === "attachment" && hasLicenseKey;
const resultItems = (searchResults || []).map((result) => ( const resultItems = (searchResults || []).map((result) => (
<SearchResultItem <SearchResultItem

View File

@ -10,7 +10,7 @@ import {
TextInput, TextInput,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react"; import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
useCreateShareMutation, useCreateShareMutation,
@ -18,27 +18,23 @@ import {
useShareForPageQuery, useShareForPageQuery,
useUpdateShareMutation, useUpdateShareMutation,
} from "@/features/share/queries/share-query.ts"; } from "@/features/share/queries/share-query.ts";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib"; import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
} }
export default function ShareModal({ readOnly }: ShareModalProps) { export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug); const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial();
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation(); const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation(); const deleteShareMutation = useDeleteShareMutation();
@ -65,7 +61,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
createShareMutation.mutateAsync({ createShareMutation.mutateAsync({
pageId: pageId, pageId: pageId,
includeSubPages: true, includeSubPages: true,
searchIndexing: false, searchIndexing: true,
}); });
setIsPagePublic(value); setIsPagePublic(value);
} else { } else {
@ -96,29 +92,26 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
}); });
}; };
const shareLink = useMemo( const shareLink = useMemo(() => (
() => ( <Group my="sm" gap={4} wrap="nowrap">
<Group my="sm" gap={4} wrap="nowrap"> <TextInput
<TextInput variant="filled"
variant="filled" value={publicLink}
value={publicLink} readOnly
readOnly rightSection={<CopyTextButton text={publicLink} />}
rightSection={<CopyTextButton text={publicLink} />} style={{ width: "100%" }}
style={{ width: "100%" }} />
/> <ActionIcon
<ActionIcon component="a"
component="a" variant="default"
variant="default" target="_blank"
target="_blank" href={publicLink}
href={publicLink} size="sm"
size="sm" >
> <IconExternalLink size={16} />
<IconExternalLink size={16} /> </ActionIcon>
</ActionIcon> </Group>
</Group> ), [publicLink]);
),
[publicLink],
);
return ( return (
<Popover width={350} position="bottom" withArrow shadow="md"> <Popover width={350} position="bottom" withArrow shadow="md">
@ -142,28 +135,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
</Button> </Button>
</Popover.Target> </Popover.Target>
<Popover.Dropdown style={{ userSelect: "none" }}> <Popover.Dropdown style={{ userSelect: "none" }}>
{isCloud() && isTrial ? ( {isDescendantShared ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center" mb="sm">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button
size="xs"
onClick={() => navigate("/settings/billing")}
fullWidth
>
{t("Upgrade Plan")}
</Button>
</>
) : isDescendantShared ? (
<> <>
<Text size="sm">{t("Inherits public sharing from")}</Text> <Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor <Anchor

View File

@ -1,10 +1,10 @@
import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core"; import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx"; import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react"; import React, {useMemo} from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceDetails from "@/features/space/components/space-details.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
@ -39,18 +39,16 @@ export default function SpaceSettingsModal({
xOffset={0} xOffset={0}
mah={400} mah={400}
> >
<Modal.Overlay /> <Modal.Overlay/>
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{overflow: "hidden"}}>
<Modal.Header py={0}> <Modal.Header py={0}>
<Modal.Title> <Modal.Title>
<Text fw={500} lineClamp={1}> <Text fw={500} lineClamp={1}>{space?.name}</Text>
{space?.name}
</Text>
</Modal.Title> </Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton/>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<div style={{ height: rem(600) }}> <div style={{height: rem(600)}}>
<Tabs defaultValue="members"> <Tabs defaultValue="members">
<Tabs.List> <Tabs.List>
<Tabs.Tab fw={500} value="general"> <Tabs.Tab fw={500} value="general">
@ -62,15 +60,13 @@ export default function SpaceSettingsModal({
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
<ScrollArea h={550} scrollbarSize={4} pr={8}> <SpaceDetails
<SpaceDetails spaceId={space?.id}
spaceId={space?.id} readOnly={spaceAbility.cannot(
readOnly={spaceAbility.cannot( SpaceCaslAction.Manage,
SpaceCaslAction.Manage, SpaceCaslSubject.Settings,
SpaceCaslSubject.Settings, )}
)} />
/>
</ScrollArea>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="members"> <Tabs.Panel value="members">
@ -78,7 +74,7 @@ export default function SpaceSettingsModal({
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Member, SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id} />} ) && <AddSpaceMembersModal spaceId={space?.id}/>}
</Group> </Group>
<SpaceMembersList <SpaceMembersList

View File

@ -1,11 +1,9 @@
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core"; import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types"; import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface SpaceSelectProps { interface SpaceSelectProps {
onChange: (value: ISpace) => void; onChange: (value: ISpace) => void;
@ -18,14 +16,7 @@ interface SpaceSelectProps {
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<CustomAvatar <Avatar color="initials" variant="filled" name={option.label} size={20} />
name={option.label}
avatarUrl={option?.["icon"]}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<div> <div>
<Text size="sm" lineClamp={1}> <Text size="sm" lineClamp={1}>
{option.label} {option.label}
@ -59,7 +50,6 @@ export function SpaceSelect({
return { return {
label: space.name, label: space.name,
value: space.slug, value: space.slug,
icon: space.logo,
}; };
}); });
@ -86,11 +76,12 @@ export function SpaceSelect({
onChange={(slug) => onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug)) onChange(spaces.items?.find((item) => item.slug === slug))
} }
// duct tape
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }} comboboxProps={{ width, withinPortal: true, position: "bottom" }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );

View File

@ -74,11 +74,7 @@ export function SpaceSidebar() {
marginBottom: 3, marginBottom: 3,
}} }}
> >
<SwitchSpace <SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
</div> </div>
<div className={classes.section}> <div className={classes.section}>

View File

@ -1,25 +1,17 @@
import classes from "./switch-space.module.css"; import classes from './switch-space.module.css';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { SpaceSelect } from "./space-select"; import { SpaceSelect } from './space-select';
import { getSpaceUrl } from "@/lib/config"; import { getSpaceUrl } from '@/lib/config';
import { Button, Popover, Text } from "@mantine/core"; import { Avatar, Button, Popover, Text } from '@mantine/core';
import { IconChevronDown } from "@tabler/icons-react"; import { IconChevronDown } from '@tabler/icons-react';
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from '@mantine/hooks';
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import React from "react";
interface SwitchSpaceProps { interface SwitchSpaceProps {
spaceName: string; spaceName: string;
spaceSlug: string; spaceSlug: string;
spaceIcon?: string;
} }
export function SwitchSpace({ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
spaceName,
spaceSlug,
spaceIcon,
}: SwitchSpaceProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [opened, { close, open, toggle }] = useDisclosure(false); const [opened, { close, open, toggle }] = useDisclosure(false);
@ -48,13 +40,11 @@ export function SwitchSpace({
color="gray" color="gray"
onClick={open} onClick={open}
> >
<CustomAvatar <Avatar
name={spaceName} size={20}
avatarUrl={spaceIcon}
type={AvatarIconType.SPACE_ICON}
color="initials" color="initials"
variant="filled" variant="filled"
size={20} name={spaceName}
/> />
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}> <Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
{spaceName} {spaceName}
@ -65,7 +55,7 @@ export function SwitchSpace({
<SpaceSelect <SpaceSelect
label={spaceName} label={spaceName}
value={spaceSlug} value={spaceSlug}
onChange={(space) => handleSelect(space.slug)} onChange={space => handleSelect(space.slug)}
width={300} width={300}
opened={true} opened={true}
/> />

View File

@ -1,23 +1,11 @@
import React, { useState } from "react"; import React from 'react';
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx"; import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Button, Divider, Text } from "@mantine/core"; import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from "./delete-space-modal"; import DeleteSpaceModal from './delete-space-modal';
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx"; import ExportModal from "@/components/common/export-modal.tsx";
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadSpaceIcon,
removeSpaceIcon,
} from "@/features/attachments/services/attachment-service.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { queryClient } from "@/main.tsx";
import {
ResponsiveSettingsContent,
ResponsiveSettingsControl,
ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@ -25,40 +13,9 @@ interface SpaceDetailsProps {
} }
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false);
const handleIconUpload = async (file: File) => {
setIsIconUploading(true);
try {
await uploadSpaceIcon(file, spaceId);
await refetch();
await queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
} catch (err) {
// skip
} finally {
setIsIconUploading(false);
}
};
const handleIconRemove = async () => {
setIsIconUploading(true);
try {
await removeSpaceIcon(spaceId);
await refetch();
await queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
} catch (err) {
// skip
} finally {
setIsIconUploading(false);
}
};
return ( return (
<> <>
@ -67,56 +24,38 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<Text my="md" fw={600}> <Text my="md" fw={600}>
{t("Details")} {t("Details")}
</Text> </Text>
<div style={{ marginBottom: "20px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={space.logo}
fallbackName={space.name}
size={"60px"}
variant="filled"
type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isIconUploading}
disabled={readOnly}
/>
</div>
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" /> <Divider my="lg" />
<ResponsiveSettingsRow> <Group justify="space-between" wrap="nowrap" gap="xl">
<ResponsiveSettingsContent> <div>
<Text size="md">{t("Export space")}</Text> <Text size="md">{t("Export space")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("Export all pages and attachments in this space.")} {t("Export all pages and attachments in this space.")}
</Text> </Text>
</ResponsiveSettingsContent> </div>
<ResponsiveSettingsControl>
<Button onClick={openExportModal}>{t("Export")}</Button> <Button onClick={openExportModal}>
</ResponsiveSettingsControl> {t("Export")}
</ResponsiveSettingsRow> </Button>
</Group>
<Divider my="lg" /> <Divider my="lg" />
<ResponsiveSettingsRow> <Group justify="space-between" wrap="nowrap" gap="xl">
<ResponsiveSettingsContent> <div>
<Text size="md">{t("Delete space")}</Text> <Text size="md">{t("Delete space")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("Delete this space with all its pages and data.")} {t("Delete this space with all its pages and data.")}
</Text> </Text>
</ResponsiveSettingsContent> </div>
<ResponsiveSettingsControl>
<DeleteSpaceModal space={space} /> <DeleteSpaceModal space={space} />
</ResponsiveSettingsControl> </Group>
</ResponsiveSettingsRow>
<ExportModal <ExportModal
type="space" type="space"

View File

@ -7,7 +7,7 @@
} }
.cardSection { .cardSection {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
} }
.title { .title {

View File

@ -1,5 +1,5 @@
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core"; import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React from "react"; import React, { useEffect } from 'react';
import { import {
prefetchSpace, prefetchSpace,
useGetSpacesQuery, useGetSpacesQuery,
@ -10,8 +10,6 @@ import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceGrid() { export default function SpaceGrid() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -29,10 +27,8 @@ export default function SpaceGrid() {
withBorder withBorder
> >
<Card.Section className={classes.cardSection} h={40}></Card.Section> <Card.Section className={classes.cardSection} h={40}></Card.Section>
<CustomAvatar <Avatar
name={space.name} name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials" color="initials"
variant="filled" variant="filled"
size="md" size="md"

View File

@ -1,4 +1,4 @@
import { Group, Table, Text } from "@mantine/core"; import { Table, Group, Text, Avatar } from "@mantine/core";
import React, { useState } from "react"; import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
@ -6,8 +6,6 @@ import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceList() { export default function SpaceList() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -41,10 +39,8 @@ export default function SpaceList() {
> >
<Table.Td> <Table.Td>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<CustomAvatar <Avatar
color="initials" color="initials"
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
variant="filled" variant="filled"
name={space.name} name={space.name}
/> />

View File

@ -6,12 +6,13 @@ import {
Box, Box,
Space, Space,
Menu, Menu,
Avatar,
Anchor, Anchor,
} from "@mantine/core"; } from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react"; import { IconDots, IconSettings } from "@tabler/icons-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import React, { useState } from "react"; import { useState } from "react";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { getSpaceUrl } from "@/lib/config"; import { getSpaceUrl } from "@/lib/config";
@ -21,8 +22,6 @@ import Paginate from "@/components/common/paginate";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
import SpaceSettingsModal from "@/features/space/components/settings-modal"; import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css"; import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface AllSpacesListProps { interface AllSpacesListProps {
spaces: any[]; spaces: any[];
@ -88,13 +87,11 @@ export default function AllSpacesList({
className={classes.spaceLink} className={classes.spaceLink}
onMouseEnter={() => prefetchSpace(space.slug, space.id)} onMouseEnter={() => prefetchSpace(space.slug, space.id)}
> >
<CustomAvatar <Avatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials" color="initials"
variant="filled" variant="filled"
size="md" name={space.name}
size={40}
/> />
<div> <div>
<Text fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>

View File

@ -152,36 +152,13 @@ export function useDeleteSpaceMutation() {
}); });
} }
// Remove space-specific queries const spaces = queryClient.getQueryData(["spaces"]) as any;
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
exact: true,
});
// Invalidate recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
queryClient.invalidateQueries({
queryKey: ["recent-changes", variables.id],
});
}
// Update spaces list cache
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) { if (spaces) {
spaces.items = spaces.items?.filter( spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id, (space: ISpace) => space.id !== variables.id,
); );
queryClient.setQueryData(["spaces"], spaces); queryClient.setQueryData(["spaces"], spaces);
}*/ }
// Invalidate all spaces queries to refresh lists
queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;

View File

@ -8,6 +8,7 @@ import {
ISpaceMember, ISpaceMember,
} from "@/features/space/types/space.types"; } from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
export async function getSpaces( export async function getSpaces(

View File

@ -9,7 +9,7 @@ export interface ISpace {
id: string; id: string;
name: string; name: string;
description: string; description: string;
logo?: string; icon: string;
slug: string; slug: string;
hostname: string; hostname: string;
creatorId: string; creatorId: string;

View File

@ -1,58 +1,55 @@
import { import { focusAtom } from "jotai-optics";
currentUserAtom, import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
userAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react"; import { useState } from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import AvatarUploader from "@/components/common/avatar-uploader.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { import { FileButton, Tooltip } from "@mantine/core";
uploadUserAvatar, import { uploadAvatar } from "@/features/user/services/user-service.ts";
removeAvatar, import { useTranslation } from "react-i18next";
} from "@/features/attachments/services/attachment-service.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountAvatar() { export default function AccountAvatar() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom); const [, setUser] = useAtom(userAtom);
const [file, setFile] = useState<File | null>(null);
const handleUpload = async (selectedFile: File) => { const handleFileChange = async (selectedFile: File) => {
setIsLoading(true); if (!selectedFile) {
try { return;
const avatar = await uploadUserAvatar(selectedFile);
if (currentUser?.user) {
setUser({ ...currentUser.user, avatarUrl: avatar.fileName });
}
} catch (err) {
// skip
} finally {
setIsLoading(false);
} }
};
const handleRemove = async () => { setFile(selectedFile);
setIsLoading(true);
try { try {
await removeAvatar(); setIsLoading(true);
if (currentUser?.user) { const avatar = await uploadAvatar(selectedFile);
setUser({ ...currentUser.user, avatarUrl: null });
} setUser((prev) => ({ ...prev, avatarUrl: avatar.fileName }));
} catch (err) { } catch (err) {
// skip console.log(err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<AvatarUploader <>
currentImageUrl={currentUser?.user.avatarUrl} <FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
fallbackName={currentUser?.user.name} {(props) => (
size="60px" <Tooltip label={t("Change photo")} position="bottom">
type={AvatarIconType.AVATAR} <CustomAvatar
onUpload={handleUpload} {...props}
onRemove={handleRemove} component="button"
isLoading={isLoading} size="60px"
/> avatarUrl={currentUser?.user.avatarUrl}
name={currentUser?.user.name}
style={{ cursor: "pointer" }}
/>
</Tooltip>
)}
</FileButton>
</>
); );
} }

View File

@ -10,3 +10,16 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
const req = await api.post<IUser>("/users/update", data); const req = await api.post<IUser>("/users/update", data);
return req.data as IUser; return req.data as IUser;
} }
export async function uploadAvatar(file: File): Promise<any> {
const formData = new FormData();
formData.append("type", "avatar");
formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req;
}

View File

@ -1,67 +0,0 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadWorkspaceIcon,
removeWorkspaceIcon,
} from "@/features/attachments/services/attachment-service.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceIcon() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const handleIconUpload = async (file: File) => {
setIsLoading(true);
try {
const result = await uploadWorkspaceIcon(file);
if (workspace) {
setWorkspace({ ...workspace, logo: result.fileName });
}
} catch (error) {
//
} finally {
setIsLoading(false);
}
};
const handleIconRemove = async () => {
setIsLoading(true);
try {
await removeWorkspaceIcon();
if (workspace) {
setWorkspace({ ...workspace, logo: null });
}
} catch (error) {
//
} finally {
setIsLoading(false);
}
};
return (
<div style={{ marginBottom: "24px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={workspace?.logo}
fallbackName={workspace?.name}
type={AvatarIconType.WORKSPACE_ICON}
size="60px"
radius="sm"
variant="filled"
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isLoading}
disabled={!isAdmin}
/>
</div>
);
}

View File

@ -109,3 +109,15 @@ export async function getAppVersion(): Promise<IVersion> {
return req.data; return req.data;
} }
export async function uploadLogo(file: File) {
const formData = new FormData();
formData.append("type", "workspace-logo");
formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req.data;
}

View File

@ -1,6 +1,5 @@
import bytes from "bytes"; import bytes from "bytes";
import { castToBoolean } from "@/lib/utils.tsx"; import { castToBoolean } from "@/lib/utils.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
declare global { declare global {
interface Window { interface Window {
@ -42,14 +41,11 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD")); return castToBoolean(getConfigValue("CLOUD"));
} }
export function getAvatarUrl( export function getAvatarUrl(avatarUrl: string) {
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
) {
if (!avatarUrl) return null; if (!avatarUrl) return null;
if (avatarUrl?.startsWith("http")) return avatarUrl; if (avatarUrl?.startsWith("http")) return avatarUrl;
return getBackendUrl() + `/attachments/img/${type}/` + encodeURI(avatarUrl); return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
} }
export function getSpaceUrl(spaceSlug: string) { export function getSpaceUrl(spaceSlug: string) {

View File

@ -2,7 +2,6 @@ export interface QueryParams {
query?: string; query?: string;
page?: number; page?: number;
limit?: number; limit?: number;
adminView?: boolean;
} }
export enum UserRole { export enum UserRole {
@ -37,3 +36,20 @@ export type IPagination<T> = {
items: T[]; items: T[];
meta: IPaginationMeta; meta: IPaginationMeta;
}; };
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}

View File

@ -1,8 +1,6 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css"; import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import '@mantine/dates/styles.css';
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme"; import { mantineCssResolver, theme } from "@/theme";

View File

@ -1,6 +1,5 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getAppName, isCloud } from "@/lib/config.ts"; import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
@ -15,7 +14,6 @@ export default function WorkspaceSettings() {
<title>Workspace Settings - {getAppName()}</title> <title>Workspace Settings - {getAppName()}</title>
</Helmet> </Helmet>
<SettingsTitle title={t("General")} /> <SettingsTitle title={t("General")} />
<WorkspaceIcon />
<WorkspaceNameForm /> <WorkspaceNameForm />
{isCloud() && ( {isCloud() && (

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.23.2", "version": "0.23.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -37,7 +37,6 @@
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.2", "@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
@ -56,15 +55,14 @@
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.61.0", "bullmq": "^5.53.2",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"happy-dom": "^18.0.1", "happy-dom": "^15.11.6",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
@ -86,12 +84,10 @@
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2", "sanitize-filename-ts": "^1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.18.2", "ws": "^8.18.2",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },

View File

@ -16,8 +16,6 @@ import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module'; import { ImportModule } from './integrations/import/import.module';
import { SecurityModule } from './integrations/security/security.module'; import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module'; import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
const enterpriseModules = []; const enterpriseModules = [];
try { try {
@ -38,9 +36,6 @@ try {
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CollaborationModule, CollaborationModule,
WsModule, WsModule,
QueueModule, QueueModule,

View File

@ -35,10 +35,11 @@ import {
Subpages, Subpages,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [

View File

@ -1,8 +1,3 @@
export enum EventName { export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated', COLLAB_PAGE_UPDATED = 'collab.page.updated',
PAGE_CREATED = 'page.created',
PAGE_UPDATED = 'page.updated',
PAGE_DELETED = 'page.deleted',
PAGE_SOFT_DELETED = 'page.soft_deleted',
PAGE_RESTORED = 'page.restored',
} }

View File

@ -1,29 +1,21 @@
import { type Extensions, type JSONContent, getSchema } from '@tiptap/core'; import { Extensions, getSchema, JSONContent } from '@tiptap/core';
import { Node } from '@tiptap/pm/model'; import { DOMSerializer, Node } from '@tiptap/pm/model';
import { getHTMLFromFragment } from './getHTMLFromFragment'; import { Window } from 'happy-dom';
/**
* This function generates HTML from a ProseMirror JSON content object.
*
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param doc - The ProseMirror JSON content object.
* @param extensions - The Tiptap extensions used to build the schema.
* @returns The generated HTML string.
* @example
* ```js
* const html = generateHTML(doc, extensions)
* console.log(html)
* ```
*/
export function generateHTML(doc: JSONContent, extensions: Extensions): string { export function generateHTML(doc: JSONContent, extensions: Extensions): string {
if (typeof window !== 'undefined') {
throw new Error(
'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
);
}
const schema = getSchema(extensions); const schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc); const contentNode = Node.fromJSON(schema, doc);
return getHTMLFromFragment(contentNode, schema); const window = new Window();
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
contentNode.content,
{
document: window.document as unknown as Document,
},
);
const serializer = new window.XMLSerializer();
// @ts-ignore
return serializer.serializeToString(fragment as unknown as Node);
} }

View File

@ -1,55 +1,21 @@
import type { Extensions } from '@tiptap/core'; import { Extensions, getSchema } from '@tiptap/core';
import { getSchema } from '@tiptap/core'; import { DOMParser, ParseOptions } from '@tiptap/pm/model';
import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model';
import { Window } from 'happy-dom'; import { Window } from 'happy-dom';
/** // this function does not work as intended
* Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content. // it has issues with closing tags
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param {string} html - The HTML string to be converted into a Prosemirror node.
* @param {Extensions} extensions - The extensions to be used for generating the schema.
* @param {ParseOptions} options - The options to be supplied to the parser.
* @returns {Promise<Record<string, any>>} - A promise with the generated JSON object.
* @example
* const html = '<p>Hello, world!</p>'
* const extensions = [...]
* const json = generateJSON(html, extensions)
* console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] }
*/
export function generateJSON( export function generateJSON(
html: string, html: string,
extensions: Extensions, extensions: Extensions,
options?: ParseOptions, options?: ParseOptions,
): Record<string, any> { ): Record<string, any> {
if (typeof window !== 'undefined') { const schema = getSchema(extensions);
throw new Error(
'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
);
}
const localWindow = new Window(); const window = new Window();
const localDOMParser = new localWindow.DOMParser(); const document = window.document;
let result: Record<string, any>; document.body.innerHTML = html;
try { return DOMParser.fromSchema(schema)
const schema = getSchema(extensions); .parse(document as never, options)
let doc: ReturnType<typeof localDOMParser.parseFromString> | null = null; .toJSON();
const htmlString = `<!DOCTYPE html><html><body>${html}</body></html>`;
doc = localDOMParser.parseFromString(htmlString, 'text/html');
if (!doc) {
throw new Error('Failed to parse HTML string');
}
result = PMDOMParser.fromSchema(schema)
.parse(doc.body as unknown as Node, options)
.toJSON();
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
} }

View File

@ -1,54 +0,0 @@
import type { Node, Schema } from '@tiptap/pm/model';
import { DOMSerializer } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
/**
* Returns the HTML string representation of a given document node.
*
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param doc - The document node to serialize.
* @param schema - The Prosemirror schema to use for serialization.
* @returns A promise containing the HTML string representation of the document fragment.
*
* @example
* ```typescript
* const html = getHTMLFromFragment(doc, schema)
* ```
*/
export function getHTMLFromFragment(
doc: Node,
schema: Schema,
options?: { document?: Document },
): string {
if (options?.document) {
const wrap = options.document.createElement('div');
DOMSerializer.fromSchema(schema).serializeFragment(
doc.content,
{ document: options.document },
wrap,
);
return wrap.innerHTML;
}
const localWindow = new Window();
let result: string;
try {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
doc.content,
{
document: localWindow.document as unknown as Document,
},
);
const serializer = new localWindow.XMLSerializer();
result = serializer.serializeToString(fragment as any);
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
}

View File

@ -72,9 +72,7 @@ export function extractDateFromUuid7(uuid7: string) {
} }
export function sanitizeFileName(fileName: string): string { export function sanitizeFileName(fileName: string): string {
const sanitizedFilename = sanitize(fileName) const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
.replace(/ /g, '_')
.replace(/#/g, '_');
return sanitizedFilename.slice(0, 255); return sanitizedFilename.slice(0, 255);
} }

View File

@ -1,34 +0,0 @@
// MIT - https://github.com/typestack/class-validator/pull/2626
import isISO6391Validator from 'validator/lib/isISO6391';
import { buildMessage, ValidateBy, ValidationOptions } from 'class-validator';
export const IS_ISO6391 = 'isISO6391';
/**
* Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
*/
export function isISO6391(value: unknown): boolean {
return typeof value === 'string' && isISO6391Validator(value);
}
/**
* Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
*/
export function IsISO6391(
validationOptions?: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
{
name: IS_ISO6391,
validator: {
validate: (value, args): boolean => isISO6391(value),
defaultMessage: buildMessage(
(eachPrefix) =>
eachPrefix + '$property must be a valid ISO 639-1 language code',
validationOptions,
),
},
},
validationOptions,
);
}

View File

@ -1,12 +1,12 @@
export enum AttachmentType { export enum AttachmentType {
Avatar = 'avatar', Avatar = 'avatar',
WorkspaceIcon = 'workspace-icon', WorkspaceLogo = 'workspace-logo',
SpaceIcon = 'space-icon', SpaceLogo = 'space-logo',
File = 'file', File = 'file',
} }
export const validImageExtensions = ['.jpg', '.png', '.jpeg']; export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '10MB'; export const MAX_AVATAR_SIZE = '5MB';
export const inlineFileExtensions = [ export const inlineFileExtensions = [
'.jpg', '.jpg',

View File

@ -1,6 +1,5 @@
import { import {
BadRequestException, BadRequestException,
Body,
Controller, Controller,
ForbiddenException, ForbiddenException,
Get, Get,
@ -52,7 +51,6 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service'; import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path'; import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
@Controller() @Controller()
export class AttachmentController { export class AttachmentController {
@ -304,7 +302,7 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type'); throw new BadRequestException('Invalid image attachment type');
} }
if (attachmentType === AttachmentType.WorkspaceIcon) { if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace); const ability = this.workspaceAbility.createForUser(user, workspace);
if ( if (
ability.cannot( ability.cannot(
@ -316,7 +314,7 @@ export class AttachmentController {
} }
} }
if (attachmentType === AttachmentType.SpaceIcon) { if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) { if (!spaceId) {
throw new BadRequestException('spaceId is required'); throw new BadRequestException('spaceId is required');
} }
@ -374,59 +372,8 @@ export class AttachmentController {
}); });
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
// this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
} }
} }
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/remove-icon')
async removeIcon(
@Body() dto: RemoveIconDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const { type, spaceId } = dto;
// remove current user avatar
if (type === AttachmentType.Avatar) {
await this.attachmentService.removeUserAvatar(user);
return;
}
// remove space icon
if (type === AttachmentType.SpaceIcon) {
if (!spaceId) {
throw new BadRequestException(
'spaceId is required to change space icons',
);
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
await this.attachmentService.removeSpaceIcon(spaceId, workspace.id);
return;
}
// remove workspace icon
if (type === AttachmentType.WorkspaceIcon) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
await this.attachmentService.removeWorkspaceIcon(workspace);
return;
}
}
} }

View File

@ -1,8 +1,8 @@
import { MultipartFile } from '@fastify/multipart'; import { MultipartFile } from '@fastify/multipart';
import { randomBytes } from 'crypto';
import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path'; import * as path from 'path';
import { AttachmentType } from './attachment.constants'; import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
export interface PreparedFile { export interface PreparedFile {
buffer: Buffer; buffer: Buffer;
@ -22,8 +22,10 @@ export async function prepareFile(
} }
try { try {
const rand = randomBytes(8).toString('hex');
const buffer = await file.toBuffer(); const buffer = await file.toBuffer();
const sanitizedFilename = sanitizeFileName(file.filename); const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
const fileName = sanitizedFilename.slice(0, 255); const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length; const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase(); const fileExtension = path.extname(file.filename).toLowerCase();
@ -56,9 +58,9 @@ export function getAttachmentFolderPath(
switch (type) { switch (type) {
case AttachmentType.Avatar: case AttachmentType.Avatar:
return `${workspaceId}/avatars`; return `${workspaceId}/avatars`;
case AttachmentType.WorkspaceIcon: case AttachmentType.WorkspaceLogo:
return `${workspaceId}/workspace-logos`; return `${workspaceId}/workspace-logo`;
case AttachmentType.SpaceIcon: case AttachmentType.SpaceLogo:
return `${workspaceId}/space-logos`; return `${workspaceId}/space-logos`;
case AttachmentType.File: case AttachmentType.File:
return `${workspaceId}/files`; return `${workspaceId}/files`;
@ -68,51 +70,3 @@ export function getAttachmentFolderPath(
} }
export const validAttachmentTypes = Object.values(AttachmentType); export const validAttachmentTypes = Object.values(AttachmentType);
export async function compressAndResizeIcon(
buffer: Buffer,
attachmentType?: AttachmentType,
): Promise<Buffer> {
try {
let sharpInstance = sharp(buffer);
const metadata = await sharpInstance.metadata();
const targetWidth = 300;
const targetHeight = 300;
// Only resize if image is larger than target dimensions
if (metadata.width > targetWidth || metadata.height > targetHeight) {
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Handle based on original format
if (metadata.format === 'png') {
// Only flatten avatars to remove transparency
if (attachmentType === AttachmentType.Avatar) {
sharpInstance = sharpInstance.flatten({
background: { r: 255, g: 255, b: 255 },
});
}
return await sharpInstance
.png({
quality: 85,
compressionLevel: 6,
})
.toBuffer();
} else {
return await sharpInstance
.jpeg({
quality: 85,
progressive: true,
mozjpeg: true,
})
.toBuffer();
}
} catch (err) {
throw err;
}
}

View File

@ -1,17 +0,0 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
AttachmentType.Avatar,
AttachmentType.SpaceIcon,
AttachmentType.WorkspaceIcon,
])
@IsNotEmpty()
type: AttachmentType;
@IsOptional()
@IsUUID()
spaceId: string;
}

View File

@ -0,0 +1,3 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class AvatarUploadDto {}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class GetFileDto {
@IsString()
@IsNotEmpty()
attachmentId: string;
}

View File

@ -0,0 +1,20 @@
import {
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UploadFileDto {
@IsString()
@IsNotEmpty()
attachmentType: string;
@IsOptional()
@IsUUID()
pageId: string;
@IsDefined()
file: any;
}

View File

@ -67,15 +67,9 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed') @OnWorkerEvent('failed')
onError(job: Job) { onError(job: Job) {
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) { this.logger.error(
this.logger.debug( `Error processing ${job.name} job. Reason: ${job.failedReason}`,
`Error processing ${job.name} job for attachment ${job.data?.attachmentId}. Reason: ${job.failedReason}`, );
);
} else {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
} }
@OnWorkerEvent('completed') @OnWorkerEvent('completed')

View File

@ -7,7 +7,6 @@ import {
import { StorageService } from '../../../integrations/storage/storage.service'; import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart'; import { MultipartFile } from '@fastify/multipart';
import { import {
compressAndResizeIcon,
getAttachmentFolderPath, getAttachmentFolderPath,
PreparedFile, PreparedFile,
prepareFile, prepareFile,
@ -17,7 +16,7 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { AttachmentType, validImageExtensions } from '../attachment.constants'; import { AttachmentType, validImageExtensions } from '../attachment.constants';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types'; import { Attachment } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
@ -133,8 +132,8 @@ export class AttachmentService {
filePromise: Promise<MultipartFile>, filePromise: Promise<MultipartFile>,
type: type:
| AttachmentType.Avatar | AttachmentType.Avatar
| AttachmentType.WorkspaceIcon | AttachmentType.WorkspaceLogo
| AttachmentType.SpaceIcon, | AttachmentType.SpaceLogo,
userId: string, userId: string,
workspaceId: string, workspaceId: string,
spaceId?: string, spaceId?: string,
@ -142,9 +141,6 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise); const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions); validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension; preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`; const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
@ -178,7 +174,7 @@ export class AttachmentService {
workspaceId, workspaceId,
trx, trx,
); );
} else if (type === AttachmentType.WorkspaceIcon) { } else if (type === AttachmentType.WorkspaceLogo) {
const workspace = await this.workspaceRepo.findById(workspaceId, { const workspace = await this.workspaceRepo.findById(workspaceId, {
trx, trx,
}); });
@ -190,7 +186,7 @@ export class AttachmentService {
workspaceId, workspaceId,
trx, trx,
); );
} else if (type === AttachmentType.SpaceIcon && spaceId) { } else if (type === AttachmentType.SpaceLogo && spaceId) {
const space = await this.spaceRepo.findById(spaceId, workspaceId, { const space = await this.spaceRepo.findById(spaceId, workspaceId, {
trx, trx,
}); });
@ -209,6 +205,7 @@ export class AttachmentService {
}); });
} catch (err) { } catch (err) {
// delete uploaded file on db update failure // delete uploaded file on db update failure
this.logger.error('Image upload error:', err);
await this.deleteRedundantFile(filePath); await this.deleteRedundantFile(filePath);
throw new BadRequestException('Failed to upload image'); throw new BadRequestException('Failed to upload image');
} }
@ -392,40 +389,4 @@ export class AttachmentService {
} }
} }
async removeUserAvatar(user: User) {
if (user.avatarUrl && !user.avatarUrl.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.Avatar, user.workspaceId)}/${user.avatarUrl}`;
await this.deleteRedundantFile(filePath);
}
await this.userRepo.updateUser(
{ avatarUrl: null },
user.id,
user.workspaceId,
);
}
async removeSpaceIcon(spaceId: string, workspaceId: string) {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (space.logo && !space.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.SpaceIcon, workspaceId)}/${space.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.spaceRepo.updateSpace({ logo: null }, spaceId, workspaceId);
}
async removeWorkspaceIcon(workspace: Workspace) {
if (workspace.logo && !workspace.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.WorkspaceIcon, workspace.id)}/${workspace.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.workspaceRepo.updateWorkspace({ logo: null }, workspace.id);
}
} }

View File

@ -4,7 +4,6 @@ export enum JwtType {
EXCHANGE = 'exchange', EXCHANGE = 'exchange',
ATTACHMENT = 'attachment', ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token', MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
} }
export type JwtPayload = { export type JwtPayload = {
sub: string; sub: string;
@ -37,10 +36,3 @@ export interface JwtMfaTokenPayload {
workspaceId: string; workspaceId: string;
type: 'mfa_token'; type: 'mfa_token';
} }
export type JwtApiKeyPayload = {
sub: string;
workspaceId: string;
apiKeyId: string;
type: 'api_key';
};

View File

@ -6,7 +6,6 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { import {
JwtApiKeyPayload,
JwtAttachmentPayload, JwtAttachmentPayload,
JwtCollabPayload, JwtCollabPayload,
JwtExchangePayload, JwtExchangePayload,
@ -78,7 +77,10 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '1h' }); return this.jwtService.sign(payload, { expiresIn: '1h' });
} }
async generateMfaToken(user: User, workspaceId: string): Promise<string> { async generateMfaToken(
user: User,
workspaceId: string,
): Promise<string> {
if (user.deactivatedAt || user.deletedAt) { if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@ -91,27 +93,6 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '5m' }); return this.jwtService.sign(payload, { expiresIn: '5m' });
} }
async generateApiToken(opts: {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: string | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
const payload: JwtApiKeyPayload = {
sub: user.id,
apiKeyId: apiKeyId,
workspaceId,
type: JwtType.API_KEY,
};
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async verifyJwt(token: string, tokenType: string) { async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(), secret: this.environmentService.getAppSecret(),

View File

@ -2,12 +2,11 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload'; import { JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader } from '../../../common/helpers'; import { extractBearerTokenFromHeader } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -17,7 +16,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private userRepo: UserRepo, private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo, private workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) { ) {
super({ super({
jwtFromRequest: (req: FastifyRequest) => { jwtFromRequest: (req: FastifyRequest) => {
@ -29,8 +27,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}); });
} }
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) { async validate(req: any, payload: JwtPayload) {
if (!payload.workspaceId) { if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@ -38,14 +36,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match'); throw new UnauthorizedException('Workspace does not match');
} }
if (payload.type === JwtType.API_KEY) {
return this.validateApiKey(req, payload as JwtApiKeyPayload);
}
if (payload.type !== JwtType.ACCESS) {
throw new UnauthorizedException();
}
const workspace = await this.workspaceRepo.findById(payload.workspaceId); const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) { if (!workspace) {
@ -59,30 +49,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, workspace }; return { user, workspace };
} }
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
let ApiKeyModule: any;
let isApiKeyModuleReady = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
isApiKeyModuleReady = true;
} catch (err) {
this.logger.debug(
'API Key module requested but enterprise module not bundled in this build',
);
isApiKeyModuleReady = false;
}
if (isApiKeyModuleReady) {
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
strict: false,
});
return ApiKeyService.validateApiKey(payload);
}
throw new UnauthorizedException('Enterprise API Key module missing');
}
} }

View File

@ -40,7 +40,6 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build(); return build();
} }
@ -56,7 +55,6 @@ function buildWorkspaceAdminAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build(); return build();
} }
@ -70,7 +68,6 @@ function buildWorkspaceMemberAbility() {
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space); can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group); can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
return build(); return build();
} }

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