Compare commits

..

70 Commits

Author SHA1 Message Date
Philipinho a7f90d6474 Merge branch 'main' into anchor-link 2025-09-21 01:58:39 +01:00
Philipinho 3c4cab0d2a v0.23.2 2025-09-18 18:00:28 +01:00
Philipinho 4de25a8b94 invalidate queries on space deletion 2025-09-18 15:52:53 +01:00
Philipinho cf5bbb10df fix import html processing 2025-09-18 15:34:13 +01:00
Philipinho ac17521717 sync 2025-09-18 13:24:16 +01:00
Philip Okugbe 9ac180f719 fix: enhance page import (#1570)
* change import process

* fix processor

* fix page name in notion import

* preserve confluence table bg color

* sync
2025-09-17 23:50:27 +01:00
Philipinho 46669fea56 (cloud) disable page sharing in trial mode 2025-09-17 23:36:13 +01:00
Pleasure1234 fe6ecdf1f1 fix: update combobox props in SpaceSelect component (#1564)
Added 'keepMounted: false' and 'dropdownPadding: 0' to comboboxProps for improved dropdown behavior and appearance in the SpaceSelect sidebar component.
2025-09-17 13:36:12 +01:00
Philipinho 29d3a8cee2 Merge branch 'main' into anchor-link 2025-09-17 02:44:38 +01:00
Philipinho 04ae1d7270 Allow lastColumnResizable in table 2025-09-15 22:34:29 +01:00
Philip Okugbe 1280f96f37 feat: implement space and workspace icons (#1558)
* feat: implement space and workspace icons
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Add Sharp package for server-side image resizing and optimization
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Support removing icons

* add workspace logo support
- add upload loader
- add white background to transparent image
- other fixes and enhancements

* dark mode

* fixes

* cleanup
2025-09-15 21:11:37 +01:00
Philipinho 61d1cf88a7 fix: reset file inputs after import 2025-09-15 12:52:31 +01:00
Philipinho f413720e15 - sync
- reinstantiate S3 client to fix file upload errors during import
- delete import zip file after use
2025-09-14 03:00:23 +01:00
Philipinho 8e16ad952a v0.23.1 2025-09-13 03:15:53 +01:00
Philip Okugbe 7ada3cb1f9 fix: page import task (#1551)
* fix import

* - fix notion importer
- support notion page icon import
- fix horizontal rule css
- rename service file

* sync

* 3 mins delay
2025-09-13 03:14:59 +01:00
Philipinho 47c54174b3 sync 2025-09-11 00:50:15 +01:00
Philipinho dc0650289d sync 2025-09-04 15:07:01 -07:00
Philipinho 091e790b83 fix attachment search in cloud 2025-09-04 14:22:40 -07:00
Philipinho ae24ea29ba v0.23.0 2025-09-04 13:42:59 -07:00
Philipinho 9df6061e1a lock file 2025-09-04 13:42:33 -07:00
Philipinho 31053e2b20 update mermaid 2025-09-04 13:41:55 -07:00
Philipinho eb8e8507ea use debug 2025-09-04 13:27:15 -07:00
Philipinho c99bfb8ef1 make print better 2025-09-04 13:22:43 -07:00
Philipinho 26ea04e2a3 sync 2025-09-04 12:25:53 -07:00
Philipinho 6cc58c57f5 sync 2025-09-04 12:16:30 -07:00
Philipinho 7d2ff346fa UI fixes 2025-09-04 11:35:04 -07:00
Philipinho b08d37fbf0 fix 2025-09-04 10:57:17 -07:00
Philipinho d43ee77617 remove debug log 2025-09-04 09:40:17 -07:00
Philipinho 5d91eb4f5f feat: queue imported attachments for indexing 2025-09-04 09:38:30 -07:00
Quinten Van Damme 3e9f6b11cc Remove version from docker-compose.yml [deprecated] (#1011) 2025-09-04 03:55:32 +01:00
Hoie Kim db55de9406 feat: progressive web app (#614)
* feat: progressive web app

* replace icons

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-09-04 01:33:52 +01:00
Philip Okugbe 1919eba340 New Crowdin updates (#1522)
* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-09-03 13:17:08 -07:00
Philip Okugbe 7951b2e0c6 New Crowdin updates (#1509)
* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2025-09-03 18:28:30 +01:00
Philipinho 73b78f625d more translations 2025-09-03 10:11:19 -07:00
Philipinho cf7534de3d fix version display 2025-09-03 09:37:29 -07:00
Philipinho adec36d544 fix: adjust margins
- use default browser highlight background
2025-09-02 21:45:38 -07:00
Philipinho f9e10805f0 sync 2025-09-02 21:38:14 -07:00
Eshwar Tangirala 00e499b3e5 Fixing extra page bug on print (#1478) 2025-09-03 05:25:48 +01:00
Sarthak Mittal 5ee6e46535 checkbox aligned to text (#1486) 2025-09-03 05:23:28 +01:00
Philip Okugbe 1f797c3d27 fix: confluence drawio import (#1518)
* POC

* WIP - working

* WIP

* WIP

* sync

* fix drawio preview image
2025-09-03 05:19:09 +01:00
Philip Okugbe f12866cf42 feat(EE): full-text search in attachments (#1502)
* feat(EE): fulltext search in attachments

* feat: global search
- search filters
- attachments search ui
- and more

* fix import

* fix import

* rename migration

* add GIN index

* fix table name

* sanitize
2025-09-02 05:27:01 +01:00
Philip Okugbe dcbb65d799 feat(EE): LDAP integration (#1515)
* LDAP - WIP

* WIP

* add hasGeneratedPassword

* fix jotai atom

* - don't require password confirmation for MFA is user has auto generated password (LDAP)
- cleanups

* fix

* reorder

* update migration

* update default

* fix type error
2025-09-02 04:59:01 +01:00
Finn Dittmar 5968764508 feat: emoji callout icon (#1323) 2025-08-31 21:16:52 +01:00
Alexander Schaber 242fb6bb57 fix: set mermaid theme based on computed color scheme (#1438) 2025-08-31 20:48:59 +01:00
Philip Okugbe 74cd890bdd feat(EE): implement SSO group sync for SAML and OIDC (#1452)
* feat: implement SSO group synchronization for SAML and OIDC

- Add group_sync column to auth_providers table
- Extract groups from SAML attributes (memberOf, groups, roles)
- Extract groups from OIDC claims (groups, roles)
- Implement case-insensitive group matching with auto-creation
- Sync user groups on each SSO login
- Ensure only one provider can have group sync enabled at a time
- Add group sync toggle to SAML and OIDC configuration forms

* rename column
2025-08-31 20:33:37 +01:00
Philipinho 509622af54 ignore type error 2025-08-31 12:20:40 -07:00
Philipinho 937386e42b fix: hide table handles in readonly mode 2025-08-31 12:08:02 -07:00
Philipinho 60a373f488 fix: readonly editor table responsiveness 2025-08-31 12:04:27 -07:00
Philip Okugbe 73ee6ee8c3 feat: subpages (child pages) list node (#1462)
* feat: subpages list node

* disable user-select

* support subpages node list in public pages
2025-08-31 18:54:52 +01:00
Mirone 7d1e5bce0d feat: table row/column drag and drop (#1467)
* chore: add dev container

* feat: add drag handle when hovering cell

* feat: add column drag and drop

* feat: add support for row drag and drop

* refactor: extract preview controllers

* fix: hover issue

* refactor: add handle controller

* chore: f

* chore: remove log

* chore: remove dev files

* feat: hide other drop indicators when table dnd working

* feat: add auto scroll and bug fix

* chore: f

* fix: firefox
2025-08-31 18:53:27 +01:00
Philip Okugbe aa58e272d6 fix: exclude deleted pages (#1494) 2025-08-31 09:11:33 +01:00
Philipinho 08135a2fba sync 2025-08-12 11:09:26 -07:00
Philipinho d92a94244f sync 2025-08-12 10:21:17 -07:00
Philipinho 66099f4657 use prosemirror decorations 2025-08-11 22:10:30 -07:00
Philipinho cefabc8683 Merge branch 'main' into anchor-link 2025-08-11 13:49:57 -07:00
Philipinho 5012a68d85 sync 2025-08-06 10:19:35 -07:00
Philip Okugbe 5a3377790e feat: debug mode env variable (#1450) 2025-08-06 18:16:30 +01:00
Philip Okugbe 3b85f4b616 fix: enforce C collation for page position ordering to ensure consistent behavior in Postgres 17+ (#1446)
- Add explicit C collation to position ordering queries to fix incorrect page placement in PostgreSQL 17+
- Ensures consistent ASCII-based ordering regardless of database locale settings
- Fixes issue where new pages were incorrectly placed at random positions instead of bottom
2025-08-04 09:49:29 +01:00
Philipinho cb2a0398c7 fix: invalidate trashed page from tree state 2025-08-04 00:42:13 -07:00
Philip Okugbe 95b7be61df fix: hide trash from can view permission (#1445) 2025-08-04 08:35:28 +01:00
Philip Okugbe b0c557272d fix nested taskList in markdown export (#1443) 2025-08-04 08:01:18 +01:00
Philip Okugbe dddfd48934 feat: add attachments support for single page exports (#1440)
* feat: add attachments support for single page exports
- Add includeAttachments option to page export modal and API
- Fix internal page url in single page exports in cloud

* remove redundant line

* preserve export state
2025-08-04 08:01:11 +01:00
Philipinho aa6eec754e fix: exclude trashed pages from position generation 2025-08-04 00:00:06 -07:00
Philip Okugbe 97a7701f5d fix local storage copy function (#1442) 2025-08-04 03:20:18 +01:00
Philipinho b97eb85d05 sync 2025-08-03 03:59:08 -07:00
Philipinho 703bfad424 Support anchor links in page mentions 2025-07-08 21:06:33 -07:00
fuscodev b82171c24c fix: uid in shared pages 2025-06-28 13:07:54 +02:00
fuscodev 1baff07e4e add nanoid by Vito0912 2025-06-27 20:27:54 +02:00
fuscodev f689291a99 Merge branch 'main' into anchor-link 2025-06-18 23:41:03 +02:00
fuscodev 0e4af65935 anchor link init 2025-06-04 20:43:47 +02:00
217 changed files with 7137 additions and 955 deletions
+4 -1
View File
@@ -43,4 +43,7 @@ POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
DISABLE_TELEMETRY=false
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
DEBUG_MODE=false
+11 -3
View File
@@ -2,10 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
<title>Docmost</title>
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Docmost" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!--meta-tags-->
</head>
<body>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.22.2",
"version": "0.23.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -40,7 +40,7 @@
"katex": "0.16.22",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.6.0",
"mermaid": "^11.11.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

@@ -53,6 +53,7 @@
"e.g Space for product team": "z.B. Bereich für das Produktteam",
"e.g Space for sales team to collaborate": "z.B. Bereich für das Vertriebsteam zur Zusammenarbeit",
"Edit": "Bearbeiten",
"Read": "Lesen",
"Edit group": "Gruppe bearbeiten",
"Email": "E-Mail",
"Enter a strong password": "Geben Sie ein starkes Passwort ein",
@@ -495,5 +496,36 @@
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
"Deleted by": "Gelöscht von",
"Deleted at": "Gelöscht am",
"Preview": "Vorschau"
"Preview": "Vorschau",
"Subpages": "Unterseiten",
"Failed to load subpages": "Fehler beim Laden von Unterseiten",
"No subpages": "Keine Unterseiten",
"Subpages (Child pages)": "Unterseiten (Untergeordnete Seiten)",
"List all subpages of the current page": "Alle Unterseiten der aktuellen Seite auflisten",
"Attachments": "Anhänge",
"All spaces": "Alle Bereiche",
"Unknown": "Unbekannt",
"Find a space": "Einen Bereich finden",
"Search in all your spaces": "In all deinen Bereichen suchen",
"Type": "Art",
"Enterprise": "Unternehmen",
"Download attachment": "Anhang herunterladen",
"Allowed email domains": "Erlaubte E-Mail-Domains",
"Only users with email addresses from these domains can signup via SSO.": "Nur Benutzer mit E-Mail-Adressen aus diesen Domains können sich über SSO registrieren.",
"Enter valid domain names separated by comma or space": "Geben Sie gültige Domainnamen ein, durch Kommas oder Leerzeichen getrennt",
"Enforce two-factor authentication": "Erzwingen der Zwei-Faktor-Authentifizierung",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Sobald es erzwungen wird, müssen alle Mitglieder die Zwei-Faktor-Authentifizierung aktivieren, um auf den Arbeitsbereich zugreifen zu können.",
"Toggle MFA enforcement": "Umschalten der MFA-Erzwingung",
"Display name": "Anzeigename",
"Allow signup": "Registrierung erlauben",
"Enabled": "Aktiviert",
"Advanced Settings": "Erweiterte Einstellungen",
"Enable TLS/SSL": "TLS/SSL aktivieren",
"Use secure connection to LDAP server": "Sichere Verbindung zum LDAP-Server verwenden",
"Group sync": "Gruppensynchronisation",
"No SSO providers found.": "Keine SSO-Anbieter gefunden.",
"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?",
"Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
"Enter a strong password": "Enter a strong password",
@@ -233,6 +234,7 @@
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
"Invite link": "Invite link",
"Copy": "Copy",
"Copy anchor link": "Copy anchor link",
"Copy to space": "Copy to space",
"Copied": "Copied",
"Duplicate": "Duplicate",
@@ -403,6 +405,7 @@
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully",
"Anchor link copied": "Anchor link copied",
"Page duplicated successfully": "Page duplicated successfully",
"Find": "Find",
"Not found": "Not found",
@@ -495,5 +498,42 @@
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview"
"Preview": "Preview",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
"Subpages (Child pages)": "Subpages (Child pages)",
"List all subpages of the current page": "List all subpages of the current page",
"Attachments": "Attachments",
"All spaces": "All spaces",
"Unknown": "Unknown",
"Find a space": "Find a space",
"Search in all your spaces": "Search in all your spaces",
"Type": "Type",
"Enterprise": "Enterprise",
"Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space",
"Enforce two-factor authentication": "Enforce two-factor authentication",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.",
"Toggle MFA enforcement": "Toggle MFA enforcement",
"Display name": "Display name",
"Allow signup": "Allow signup",
"Enabled": "Enabled",
"Advanced Settings": "Advanced Settings",
"Enable TLS/SSL": "Enable TLS/SSL",
"Use secure connection to LDAP server": "Use secure connection to LDAP server",
"Group sync": "Group sync",
"No SSO providers found.": "No SSO providers found.",
"Delete SSO provider": "Delete SSO provider",
"Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?",
"Action": "Action",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration",
"Icon": "Icon",
"Upload image": "Upload image",
"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"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "ej: Espacio para el equipo de producto",
"e.g Space for sales team to collaborate": "ej: Espacio para que el equipo de ventas colabore",
"Edit": "Editar",
"Read": "Leer",
"Edit group": "Editar grupo",
"Email": "Correo electrónico",
"Enter a strong password": "Introduce una contraseña fuerte",
@@ -495,5 +496,36 @@
"Page restored successfully": "Página restaurada con éxito",
"Deleted by": "Eliminado por",
"Deleted at": "Eliminado en",
"Preview": "Vista previa"
"Preview": "Vista previa",
"Subpages": "Subpáginas",
"Failed to load subpages": "Error al cargar subpáginas",
"No subpages": "Sin subpáginas",
"Subpages (Child pages)": "Subpáginas (Páginas hijas)",
"List all subpages of the current page": "Listar todas las subpáginas de la página actual",
"Attachments": "Adjuntos",
"All spaces": "Todos los espacios",
"Unknown": "Desconocido",
"Find a space": "Encontrar un espacio",
"Search in all your spaces": "Buscar en todos tus espacios",
"Type": "Tipo",
"Enterprise": "Empresa",
"Download attachment": "Descargar adjunto",
"Allowed email domains": "Dominios de correo electrónico permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Solo los usuarios con direcciones de correo electrónico de estos dominios pueden registrarse a través de SSO.",
"Enter valid domain names separated by comma or space": "Introduce nombres de dominio válidos separados por coma o espacio",
"Enforce two-factor authentication": "Aplicar autenticación de dos factores",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Una vez aplicada, todos los miembros deben habilitar la autenticación de dos factores para acceder al espacio de trabajo.",
"Toggle MFA enforcement": "Alternar la aplicación de MFA",
"Display name": "Nombre para mostrar",
"Allow signup": "Permitir registro",
"Enabled": "Habilitado",
"Advanced Settings": "Configuración avanzada",
"Enable TLS/SSL": "Habilitar TLS/SSL",
"Use secure connection to LDAP server": "Usar conexión segura al servidor LDAP",
"Group sync": "Sincronización de grupos",
"No SSO providers found.": "No se encontraron proveedores 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?",
"Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "par ex. Espace pour l'équipe produit",
"e.g Space for sales team to collaborate": "par ex. Espace pour l'équipe de vente pour collaborer",
"Edit": "Modifier",
"Read": "Lire",
"Edit group": "Modifier groupe",
"Email": "Email",
"Enter a strong password": "Entrez un mot de passe fort",
@@ -495,5 +496,36 @@
"Page restored successfully": "Page restaurée avec succès",
"Deleted by": "Supprimé par",
"Deleted at": "Supprimé à",
"Preview": "Aperçu"
"Preview": "Aperçu",
"Subpages": "Sous-pages",
"Failed to load subpages": "Échec du chargement des sous-pages",
"No subpages": "Pas de sous-pages",
"Subpages (Child pages)": "Sous-pages (Pages enfants)",
"List all subpages of the current page": "Lister toutes les sous-pages de la page actuelle",
"Attachments": "Pièces jointes",
"All spaces": "Tous les espaces",
"Unknown": "Inconnu",
"Find a space": "Trouver un espace",
"Search in all your spaces": "Rechercher dans tous vos espaces",
"Type": "Type",
"Enterprise": "Entreprise",
"Download attachment": "Télécharger la pièce jointe",
"Allowed email domains": "Domaines de messagerie autorisés",
"Only users with email addresses from these domains can signup via SSO.": "Seuls les utilisateurs possédant des adresses e-mail provenant de ces domaines peuvent s'inscrire via SSO.",
"Enter valid domain names separated by comma or space": "Entrez des noms de domaine valides séparés par une virgule ou un espace",
"Enforce two-factor authentication": "Imposer l'authentification à deux facteurs",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Une fois appliquée, tous les membres doivent activer l'authentification à deux facteurs pour accéder à l'espace de travail.",
"Toggle MFA enforcement": "Basculer l'application de l'AMF",
"Display name": "Nom d'affichage",
"Allow signup": "Autoriser l'inscription",
"Enabled": "Activé",
"Advanced Settings": "Paramètres avancés",
"Enable TLS/SSL": "Activer TLS/SSL",
"Use secure connection to LDAP server": "Utiliser une connexion sécurisée au serveur LDAP",
"Group sync": "Synchronisation de groupe",
"No SSO providers found.": "Aucun fournisseur SSO trouvé.",
"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 ?",
"Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "es. Spazio per il team di prodotto",
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
"Edit": "Modifica",
"Read": "Leggi",
"Edit group": "Modifica gruppo",
"Email": "Email",
"Enter a strong password": "Inserisci una password sicura",
@@ -495,5 +496,36 @@
"Page restored successfully": "Pagina ripristinata con successo",
"Deleted by": "Eliminato da",
"Deleted at": "Eliminato il",
"Preview": "Anteprima"
"Preview": "Anteprima",
"Subpages": "Sottopagine",
"Failed to load subpages": "Caricamento delle sottopagine non riuscito",
"No subpages": "Nessuna sottopagina",
"Subpages (Child pages)": "Sottopagine (Pagine figlie)",
"List all subpages of the current page": "Elenca tutte le sottopagine della pagina corrente",
"Attachments": "Allegati",
"All spaces": "Tutti gli spazi",
"Unknown": "Sconosciuto",
"Find a space": "Trova uno spazio",
"Search in all your spaces": "Cerca in tutti i tuoi spazi",
"Type": "Tipo",
"Enterprise": "Impresa",
"Download attachment": "Scarica allegato",
"Allowed email domains": "Domini email consentiti",
"Only users with email addresses from these domains can signup via SSO.": "Solo gli utenti con indirizzi email provenienti da questi domini possono registrarsi tramite SSO.",
"Enter valid domain names separated by comma or space": "Inserisci nomi di dominio validi separati da virgole o spazi",
"Enforce two-factor authentication": "Imponi l'autenticazione a due fattori",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Una volta impostata, tutti i membri devono abilitare l'autenticazione a due fattori per accedere all'area di lavoro.",
"Toggle MFA enforcement": "Attiva disattiva l'applicazione MFA",
"Display name": "Nome visualizzato",
"Allow signup": "Consenti iscrizione",
"Enabled": "Abilitato",
"Advanced Settings": "Impostazioni avanzate",
"Enable TLS/SSL": "Abilita TLS/SSL",
"Use secure connection to LDAP server": "Usa connessione sicura al server LDAP",
"Group sync": "Sincronizzazione gruppi",
"No SSO providers found.": "Nessun provider SSO trovato.",
"Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "例: 製品チームのスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集",
"Read": "読む",
"Edit group": "グループを編集",
"Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください",
@@ -495,5 +496,36 @@
"Page restored successfully": "ページが正常に復元されました",
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー"
"Preview": "プレビュー",
"Subpages": "サブページ",
"Failed to load subpages": "サブページの読み込みに失敗しました",
"No subpages": "サブページがありません",
"Subpages (Child pages)": "サブページ(子ページ)",
"List all subpages of the current page": "現在のページのすべてのサブページをリスト",
"Attachments": "添付ファイル",
"All spaces": "すべてのスペース",
"Unknown": "不明",
"Find a space": "スペースを探す",
"Search in all your spaces": "あなたのすべてのスペースで検索",
"Type": "タイプ",
"Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみがSSOで登録できます。",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります。",
"Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名",
"Allow signup": "登録を許可する",
"Enabled": "有効",
"Advanced Settings": "詳細設定",
"Enable TLS/SSL": "TLS/SSLを有効にする",
"Use secure connection to LDAP server": "LDAPサーバーへの安全な接続を使用する",
"Group sync": "グループ同期",
"No SSO providers found.": "SSOプロバイダーが見つかりませんでした。",
"Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
"Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "예: 제품 팀을 위한 Space",
"e.g Space for sales team to collaborate": "예: 영업 팀의 Space",
"Edit": "편집",
"Read": "읽기",
"Edit group": "팀 편집",
"Email": "이메일",
"Enter a strong password": "강력한 비밀번호를 입력하세요",
@@ -495,5 +496,36 @@
"Page restored successfully": "페이지가 성공적으로 복구되었습니다",
"Deleted by": "삭제자",
"Deleted at": "삭제 시간",
"Preview": "미리보기"
"Preview": "미리보기",
"Subpages": "하위 페이지",
"Failed to load subpages": "하위 페이지 로드 실패",
"No subpages": "하위 페이지 없음",
"Subpages (Child pages)": "하위 페이지 (자식 페이지)",
"List all subpages of the current page": "현재 페이지의 모든 하위 페이지 목록",
"Attachments": "첨부 파일",
"All spaces": "전체 공간",
"Unknown": "알 수 없음",
"Find a space": "공간 찾기",
"Search in all your spaces": "모든 공간에서 검색",
"Type": "유형",
"Enterprise": "기업",
"Download attachment": "첨부 파일 다운로드",
"Allowed email domains": "허용된 이메일 도메인",
"Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.",
"Enter valid domain names separated by comma or space": "콤마 또는 공백으로 구분하여 유효한 도메인 이름 입력",
"Enforce two-factor authentication": "이중 인증 시행",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 작업 공간에 액세스하기 위해 이중 인증을 활성화해야 합니다.",
"Toggle MFA enforcement": "MFA 시행 전환",
"Display name": "표시 이름",
"Allow signup": "가입 허용",
"Enabled": "활성화됨",
"Advanced Settings": "고급 설정",
"Enable TLS/SSL": "TLS\\/SSL 활성화",
"Use secure connection to LDAP server": "LDAP 서버에 안전한 연결 사용",
"Group sync": "그룹 동기화",
"No SSO providers found.": "SSO 제공자를 찾을 수 없습니다.",
"Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "bijv. Ruimte voor productteam",
"e.g Space for sales team to collaborate": "bijv. Ruimte voor verkoopteam om samen te werken",
"Edit": "Bewerken",
"Read": "Lezen",
"Edit group": "Groep bewerken",
"Email": "E-mailadres",
"Enter a strong password": "Voer een sterk wachtwoord in",
@@ -495,5 +496,36 @@
"Page restored successfully": "Pagina succesvol hersteld",
"Deleted by": "Verwijderd door",
"Deleted at": "Verwijderd op",
"Preview": "Voorbeeld"
"Preview": "Voorbeeld",
"Subpages": "Subpagina's",
"Failed to load subpages": "Laden van subpagina's mislukt",
"No subpages": "Geen subpagina's",
"Subpages (Child pages)": "Subpagina's (Kindpagina's)",
"List all subpages of the current page": "Lijst van alle subpagina's van de huidige pagina",
"Attachments": "Bijlagen",
"All spaces": "Alle ruimtes",
"Unknown": "Onbekend",
"Find a space": "Vind een ruimte",
"Search in all your spaces": "Zoek in al je ruimtes",
"Type": "Type",
"Enterprise": "Onderneming",
"Download attachment": "Bijlage downloaden",
"Allowed email domains": "Toegestane e-maildomeinen",
"Only users with email addresses from these domains can signup via SSO.": "Alleen gebruikers met e-mailadressen van deze domeinen kunnen zich aanmelden via SSO.",
"Enter valid domain names separated by comma or space": "Voer geldige domeinnamen in, gescheiden door komma of spatie",
"Enforce two-factor authentication": "Handhaaf tweefactorauthenticatie",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Na handhaving moeten alle leden tweefactorauthenticatie inschakelen om toegang te krijgen tot de werkomgeving.",
"Toggle MFA enforcement": "Schakel MFA-handhaving in of uit",
"Display name": "Weergavenaam",
"Allow signup": "Aanmelden toestaan",
"Enabled": "Ingeschakeld",
"Advanced Settings": "Geavanceerde instellingen",
"Enable TLS/SSL": "TLS/SSL inschakelen",
"Use secure connection to LDAP server": "Gebruik een beveiligde verbinding met de LDAP-server",
"Group sync": "Groepssynchronisatie",
"No SSO providers found.": "Geen SSO-providers gevonden.",
"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?",
"Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "ex.: Espaço para a equipe de produto",
"e.g Space for sales team to collaborate": "ex.: Espaço para a equipe de vendas colaborar",
"Edit": "Editar",
"Read": "Ler",
"Edit group": "Editar grupo",
"Email": "Email",
"Enter a strong password": "Insira uma senha forte",
@@ -495,5 +496,36 @@
"Page restored successfully": "Página restaurada com sucesso",
"Deleted by": "Excluído por",
"Deleted at": "Excluído em",
"Preview": "Visualização"
"Preview": "Visualização",
"Subpages": "Subpáginas",
"Failed to load subpages": "Falha ao carregar subpáginas",
"No subpages": "Sem subpáginas",
"Subpages (Child pages)": "Subpáginas (Páginas filhas)",
"List all subpages of the current page": "Listar todas as subpáginas da página atual",
"Attachments": "Anexos",
"All spaces": "Todos os espaços",
"Unknown": "Desconhecido",
"Find a space": "Encontrar um espaço",
"Search in all your spaces": "Pesquisar em todos os seus espaços",
"Type": "Tipo",
"Enterprise": "Empresa",
"Download attachment": "Baixar anexo",
"Allowed email domains": "Domínios de email permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Apenas usuários com endereços de email desses domínios podem se inscrever via SSO.",
"Enter valid domain names separated by comma or space": "Insira nomes de domínio válidos separados por vírgula ou espaço",
"Enforce two-factor authentication": "Impor autenticação de dois fatores",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Uma vez imposto, todos os membros devem habilitar a autenticação de dois fatores para acessar o espaço de trabalho.",
"Toggle MFA enforcement": "Alternar imposição de MFA",
"Display name": "Nome de exibição",
"Allow signup": "Permitir inscrição",
"Enabled": "Habilitado",
"Advanced Settings": "Configurações Avançadas",
"Enable TLS/SSL": "Habilitar TLS/SSL",
"Use secure connection to LDAP server": "Usar conexão segura com o servidor LDAP",
"Group sync": "Sincronização de grupo",
"No SSO providers found.": "Nenhum provedor de SSO encontrado.",
"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?",
"Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "например, Пространство для продуктовой команды",
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
"Edit": "Редактировать",
"Read": "Читать",
"Edit group": "Редактировать группу",
"Email": "Электронная почта",
"Enter a strong password": "Введите надёжный пароль",
@@ -495,5 +496,36 @@
"Page restored successfully": "Страница успешно восстановлена",
"Deleted by": "Удалено пользователем",
"Deleted at": "Удалено в",
"Preview": "Предпросмотр"
"Preview": "Предпросмотр",
"Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все подстраницы текущей страницы",
"Attachments": "Вложения",
"All spaces": "Все пространства",
"Unknown": "Неизвестно",
"Find a space": "Найти пространство",
"Search in all your spaces": "Поиск во всех ваших пространствах",
"Type": "Тип",
"Enterprise": "Предприятие",
"Download attachment": "Скачать вложение",
"Allowed email domains": "Разрешенные домены электронной почты",
"Only users with email addresses from these domains can signup via SSO.": "Только пользователи с электронными адресами из этих доменов могут зарегистрироваться через SSO.",
"Enter valid domain names separated by comma or space": "Введите допустимые доменные имена, разделённые запятыми или пробелами",
"Enforce two-factor authentication": "Обязательная двухфакторная аутентификация",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "После введения обязательности все участники должны будут включить двухфакторную аутентификацию для доступа к рабочему пространству.",
"Toggle MFA enforcement": "Переключить обязательность MFA",
"Display name": "Отображаемое имя",
"Allow signup": "Разрешить регистрацию",
"Enabled": "Включено",
"Advanced Settings": "Расширенные настройки",
"Enable TLS/SSL": "Включить TLS/SSL",
"Use secure connection to LDAP server": "Использовать защищённое соединение с сервером LDAP",
"Group sync": "Синхронизация группы",
"No SSO providers found.": "Поставщики SSO не найдены.",
"Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "наприклад, Простір для продуктової команди",
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
"Edit": "Редагувати",
"Read": "Читати",
"Edit group": "Редагувати групу",
"Email": "Електронна пошта",
"Enter a strong password": "Введіть надійний пароль",
@@ -495,5 +496,36 @@
"Page restored successfully": "Сторінку успішно відновлено",
"Deleted by": "Видалено",
"Deleted at": "Видалено о",
"Preview": "Попередній перегляд"
"Preview": "Попередній перегляд",
"Subpages": "Підсторінки",
"Failed to load subpages": "Не вдалося завантажити підсторінки",
"No subpages": "Немає підсторінок",
"Subpages (Child pages)": "Підсторінки (дочірні сторінки)",
"List all subpages of the current page": "Перелік всіх підсторінок поточної сторінки",
"Attachments": "Вкладення",
"All spaces": "Усі простори",
"Unknown": "Невідомо",
"Find a space": "Знайти простір",
"Search in all your spaces": "Шукати у всіх ваших просторах",
"Type": "Тип",
"Enterprise": "Підприємство",
"Download attachment": "Завантажити вкладення",
"Allowed email domains": "Дозволені домени електронної пошти",
"Only users with email addresses from these domains can signup via SSO.": "Лише користувачі з адресами електронної пошти з цих доменів можуть реєструватися через SSO.",
"Enter valid domain names separated by comma or space": "Введіть дійсні доменні імена, розділені комою або пробілом",
"Enforce two-factor authentication": "Вимагати двофакторну автентифікацію",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Після увімкнення всі учасники повинні ввімкнути двофакторну автентифікацію для доступу до робочого простору.",
"Toggle MFA enforcement": "Перемикання вимоги MFA",
"Display name": "Відображуване ім'я",
"Allow signup": "Дозволити реєстрацію",
"Enabled": "Увімкнено",
"Advanced Settings": "Розширені налаштування",
"Enable TLS/SSL": "Увімкнути TLS/SSL",
"Use secure connection to LDAP server": "Використовувати захищене з'єднання з сервером LDAP",
"Group sync": "Синхронізація групи",
"No SSO providers found.": "Постачальників SSO не знайдено.",
"Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
}
@@ -53,6 +53,7 @@
"e.g Space for product team": "例如:产品团队的空间",
"e.g Space for sales team to collaborate": "例如:销售团队协作的空间",
"Edit": "编辑",
"Read": "阅读",
"Edit group": "编辑群组",
"Email": "电子邮箱",
"Enter a strong password": "输入一个强密码",
@@ -495,5 +496,36 @@
"Page restored successfully": "页面恢复成功",
"Deleted by": "删除人",
"Deleted at": "删除时间",
"Preview": "预览"
"Preview": "预览",
"Subpages": "子页面",
"Failed to load subpages": "加载子页面失败",
"No subpages": "没有子页面",
"Subpages (Child pages)": "子页面(子页面)",
"List all subpages of the current page": "列出当前页面的所有子页面",
"Attachments": "附件",
"All spaces": "所有空间",
"Unknown": "未知",
"Find a space": "查找空间",
"Search in all your spaces": "在您的所有空间中搜索",
"Type": "类型",
"Enterprise": "企业",
"Download attachment": "下载附件",
"Allowed email domains": "允许的电子邮件域",
"Only users with email addresses from these domains can signup via SSO.": "只有来自这些域的电子邮件地址的用户才能通过SSO注册。",
"Enter valid domain names separated by comma or space": "输入用逗号或空格分隔的有效域名",
"Enforce two-factor authentication": "强制实施双因素认证",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一旦实施,所有成员必须启用双因素认证才能访问工作区。",
"Toggle MFA enforcement": "切换多因素认证实施",
"Display name": "显示名称",
"Allow signup": "允许注册",
"Enabled": "已启用",
"Advanced Settings": "高级设置",
"Enable TLS/SSL": "启用TLS/SSL",
"Use secure connection to LDAP server": "使用安全连接到LDAP服务器",
"Group sync": "组同步",
"No SSO providers found.": "未找到SSO提供商。",
"Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
"Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "Docmost",
"short_name": "Docmost",
"start_url": "/",
"display": "standalone",
"background_color": "#222",
"theme_color": "#222",
"icons": [
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+2 -5
View File
@@ -34,7 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/trash.tsx";
import SpaceTrash from "@/pages/space/space-trash.tsx";
export default function App() {
const { t } = useTranslation();
@@ -50,10 +50,7 @@ export default function App() {
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route
path={"/login/mfa/setup"}
element={<MfaSetupRequiredPage />}
/>
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -0,0 +1,165 @@
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>
);
}
@@ -29,19 +29,22 @@ export default function ExportModal({
}: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const { t } = useTranslation();
const handleExport = async () => {
try {
if (type === "page") {
await exportPage({ pageId: id, format, includeChildren });
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
}
setIncludeChildren(false);
setIncludeAttachments(true);
onClose();
} catch (err) {
notifications.show({
@@ -96,6 +99,18 @@ export default function ExportModal({
checked={includeChildren}
/>
</Group>
<Group justify="space-between" wrap="nowrap" mt="md">
<div>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}
@@ -14,6 +14,14 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { isCloud } from "@/lib/config.ts";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -79,6 +87,15 @@ export function AppHeader() {
</Group>
</Group>
<div>
<Group visibleFrom="sm">
<SearchControl onClick={searchSpotlight.open} />
</Group>
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={searchSpotlight.open} />
</Group>
</div>
<Group px={"xl"} wrap="nowrap">
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
@@ -1,16 +1,23 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import { Outlet, useParams } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
import React from "react";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
export default function Layout() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
return (
<UserProvider>
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
<SearchSpotlight spaceId={space?.id} />
</UserProvider>
);
}
@@ -1,8 +1,8 @@
import {
Group,
Menu,
UnstyledButton,
Text,
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import {
@@ -10,7 +10,6 @@ import {
IconBrush,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDeviceDesktop,
IconLogout,
IconMoon,
@@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function TopMenu() {
const { t } = useTranslation();
@@ -50,6 +50,7 @@ export default function TopMenu() {
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}
@@ -50,7 +50,7 @@ export default function AppVersion() {
href="https://github.com/docmost/docmost/releases"
target="_blank"
>
v{APP_VERSION}
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
</Text>
</Indicator>
</Tooltip>
@@ -1,6 +1,7 @@
import React from "react";
import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl: string;
@@ -11,13 +12,15 @@ interface CustomAvatarProps {
variant?: string;
style?: any;
component?: any;
type?: AvatarIconType;
mt?: string | number;
}
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl);
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
return (
<Avatar
+12 -1
View File
@@ -15,6 +15,11 @@ export interface EmojiPickerInterface {
icon: ReactNode;
removeEmojiAction: () => void;
readOnly: boolean;
actionIconProps?: {
size?: string;
variant?: string;
c?: string;
};
}
function EmojiPicker({
@@ -22,6 +27,7 @@ function EmojiPicker({
icon,
removeEmojiAction,
readOnly,
actionIconProps,
}: EmojiPickerInterface) {
const { t } = useTranslation();
const [opened, handlers] = useDisclosure(false);
@@ -64,7 +70,12 @@ function EmojiPicker({
closeOnEscape={true}
>
<Popover.Target ref={setTarget}>
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
<ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
onClick={handlers.toggle}
>
{icon}
</ActionIcon>
</Popover.Target>
@@ -0,0 +1,47 @@
import { Box } from "@mantine/core";
import React from "react";
interface ResponsiveSettingsRowProps {
children: React.ReactNode;
}
export function ResponsiveSettingsRow({ children }: ResponsiveSettingsRowProps) {
return (
<Box
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
{children}
</Box>
);
}
interface ResponsiveSettingsContentProps {
children: React.ReactNode;
}
export function ResponsiveSettingsContent({ children }: ResponsiveSettingsContentProps) {
return (
<Box style={{ flex: "1 1 300px", minWidth: 0 }}>
{children}
</Box>
);
}
interface ResponsiveSettingsControlProps {
children: React.ReactNode;
}
export function ResponsiveSettingsControl({ children }: ResponsiveSettingsControlProps) {
return (
<Box style={{ flex: "0 0 auto" }}>
{children}
</Box>
);
}
@@ -0,0 +1,124 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
password: z.string().min(1, { message: "Password is required" }),
});
interface LdapLoginModalProps {
opened: boolean;
onClose: () => void;
provider: IAuthProvider;
workspaceId: string;
}
export function LdapLoginModal({
opened,
onClose,
provider,
workspaceId,
}: LdapLoginModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
username: "",
password: "",
},
});
const handleSubmit = async (values: {
username: string;
password: string;
}) => {
setIsLoading(true);
setError(null);
try {
const response = await ldapLogin({
username: values.username,
password: values.password,
providerId: provider.id,
workspaceId,
});
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
onClose();
navigate(APP_ROUTE.HOME);
}
} catch (err: any) {
setIsLoading(false);
const errorMessage =
err.response?.data?.message || "Authentication failed";
setError(errorMessage);
notifications.show({
message: errorMessage,
color: "red",
});
}
};
const handleClose = () => {
form.reset();
setError(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={`LDAP Login - ${provider.name}`}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
id="ldap-username"
type="text"
label={t("LDAP username")}
placeholder="Enter your LDAP username"
variant="filled"
disabled={isLoading}
data-autofocus
{...form.getInputProps("username")}
/>
<PasswordInput
label={t("LDAP password")}
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign in with LDAP")}
</Button>
</Stack>
</form>
</Modal>
);
}
+40 -13
View File
@@ -1,29 +1,62 @@
import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import { IconLock, IconServer } from "@tabler/icons-react";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => {
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
if (provider.type === SSO_PROVIDER.LDAP) {
// Open modal for LDAP instead of redirecting
setSelectedLdapProvider(provider);
setLdapModalOpened(true);
} else {
// Redirect for other SSO providers
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
}
};
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
} else if (provider.type === SSO_PROVIDER.LDAP) {
return <IconServer size={16} />;
} else {
return <IconLock size={16} />;
}
};
return (
<>
{selectedLdapProvider && (
<LdapLoginModal
opened={ldapModalOpened}
onClose={() => {
setLdapModalOpened(false);
setSelectedLdapProvider(null);
}}
provider={selectedLdapProvider}
workspaceId={data.id}
/>
)}
{(isCloud() || data.hasLicenseKey) && (
<>
<Stack align="stretch" justify="center" gap="sm">
@@ -31,13 +64,7 @@ export default function SsoLogin() {
<div key={provider.id}>
<Button
onClick={() => handleSsoLogin(provider)}
leftSection={
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
leftSection={getProviderIcon(provider)}
variant="default"
fullWidth
>
@@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error}
autoFocus
data-autofocus
maxLength={8}
styles={{
input: {
@@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps {
opened: boolean;
onClose: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaBackupCodesModal({
opened,
onClose,
}: MfaBackupCodesModalProps) {
const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showNewCodes, setShowNewCodes] = useState(false);
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({
validate: zodResolver(formSchema),
@@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
});
const regenerateMutation = useMutation({
mutationFn: (data: { confirmPassword: string }) =>
mutationFn: (data: { confirmPassword?: string }) =>
regenerateBackupCodes(data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes);
@@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
},
});
const handleRegenerate = (values: { confirmPassword: string }) => {
regenerateMutation.mutate(values);
const handleRegenerate = (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
regenerateMutation.mutate(payload);
};
const handleClose = () => {
@@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
)}
</Text>
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
/>
{requiresPassword && (
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
)}
<Button
type="submit"
@@ -97,6 +97,7 @@ export function MfaChallenge() {
length={6}
type="number"
autoFocus
data-autofocus
oneTimeCode
{...form.getInputProps("code")}
error={!!form.errors.code}
@@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaDisableModalProps {
opened: boolean;
@@ -22,16 +23,22 @@ interface MfaDisableModalProps {
onComplete: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({
opened,
onClose,
onComplete,
}: MfaDisableModalProps) {
const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({
validate: zodResolver(formSchema),
@@ -54,8 +61,12 @@ export function MfaDisableModal({
},
});
const handleSubmit = async (values: { confirmPassword: string }) => {
await disableMutation.mutateAsync(values);
const handleSubmit = async (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
};
const handleClose = () => {
@@ -85,18 +96,23 @@ export function MfaDisableModal({
</Text>
</Alert>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
{requiresPassword && (
<>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
</>
)}
<Stack gap="sm">
<Button
@@ -9,6 +9,7 @@ import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
export function MfaSettings() {
const { t } = useTranslation();
@@ -53,8 +54,8 @@ export function MfaSettings() {
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div style={{ minWidth: 0, flex: 1 }}>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("2-step verification")}</Text>
<Text size="sm" c="dimmed">
{!isMfaEnabled
@@ -63,44 +64,46 @@ export function MfaSettings() {
)
: t("Two-factor authentication is active on your account.")}
</Text>
</div>
</ResponsiveSettingsContent>
{!isMfaEnabled ? (
<Tooltip
label={t("Available in enterprise edition")}
disabled={canUseMfa}
>
<Button
disabled={!canUseMfa}
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
<ResponsiveSettingsControl>
{!isMfaEnabled ? (
<Tooltip
label={t("Available in enterprise edition")}
disabled={canUseMfa}
>
{t("Add 2FA method")}
</Button>
</Tooltip>
) : (
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
size="sm"
onClick={() => setBackupCodesModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
</Button>
<Button
variant="default"
size="sm"
color="red"
onClick={() => setDisableModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</Group>
<Button
disabled={!canUseMfa}
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Add 2FA method")}
</Button>
</Tooltip>
) : (
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
size="sm"
onClick={() => setBackupCodesModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
</Button>
<Button
variant="default"
size="sm"
color="red"
onClick={() => setDisableModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<MfaSetupModal
opened={setupModalOpen}
@@ -235,6 +235,7 @@ export function MfaSetupModal({
length={6}
type="number"
autoFocus
data-autofocus
oneTimeCode
{...form.getInputProps("verificationCode")}
styles={{
@@ -37,7 +37,7 @@ export async function disableMfa(
}
export async function regenerateBackupCodes(data: {
confirmPassword: string;
confirmPassword?: string;
}): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes",
+1 -1
View File
@@ -46,7 +46,7 @@ export interface MfaEnableResponse {
}
export interface MfaDisableRequest {
confirmPassword: string;
confirmPassword?: string;
}
export interface MfaBackupCodesResponse {
@@ -1,6 +1,7 @@
import { useAtom } from "jotai";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";
@@ -54,9 +55,11 @@ export default function AllowedDomains() {
return (
<>
<div>
<Text size="md">Allowed email domains</Text>
<Text size="md">{t("Allowed email domains")}</Text>
<Text size="sm" c="dimmed">
Only users with email addresses from these domains can signup via SSO.
{t(
"Only users with email addresses from these domains can signup via SSO.",
)}
</Text>
</div>
<form onSubmit={form.onSubmit(handleSubmit)}>
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { Button, Menu, Group } from "@mantine/core";
import { IconChevronDown, IconLock } from "@tabler/icons-react";
import { IconChevronDown, IconLock, IconServer } from "@tabler/icons-react";
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@@ -40,6 +40,19 @@ export default function CreateSsoProvider() {
}
};
const handleCreateLDAP = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.LDAP,
name: "LDAP",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create LDAP provider", error);
}
};
return (
<>
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
@@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
>
OpenID (OIDC)
</Menu.Item>
<Menu.Item
onClick={handleCreateLDAP}
leftSection={<IconServer size={16} />}
>
LDAP / Active Directory
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
@@ -1,6 +1,7 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@@ -0,0 +1,228 @@
import React from "react";
import { z } from "zod";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
Group,
Stack,
Switch,
TextInput,
Textarea,
Text,
Accordion,
} from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { IconInfoCircle } from "@tabler/icons-react";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
ldapUrl: z.string().url().startsWith("ldap", "Must be an LDAP URL"),
ldapBindDn: z.string().min(1, "Bind DN is required"),
ldapBindPassword: z.string().min(1, "Bind password is required"),
ldapBaseDn: z.string().min(1, "Base DN is required"),
ldapUserSearchFilter: z.string().optional(),
ldapTlsEnabled: z.boolean(),
ldapTlsCaCert: z.string().optional(),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
ldapUrl: provider.ldapUrl || "",
ldapBindDn: provider.ldapBindDn || "",
ldapBindPassword: provider.ldapBindPassword || "",
ldapBaseDn: provider.ldapBaseDn || "",
ldapUserSearchFilter:
provider.ldapUserSearchFilter || "(mail={{username}})",
ldapTlsEnabled: provider.ldapTlsEnabled || false,
ldapTlsCaCert: provider.ldapTlsCaCert || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("ldapUrl")) {
ssoData.ldapUrl = values.ldapUrl;
}
if (form.isDirty("ldapBindDn")) {
ssoData.ldapBindDn = values.ldapBindDn;
}
if (form.isDirty("ldapBindPassword")) {
ssoData.ldapBindPassword = values.ldapBindPassword;
}
if (form.isDirty("ldapBaseDn")) {
ssoData.ldapBaseDn = values.ldapBaseDn;
}
if (form.isDirty("ldapUserSearchFilter")) {
ssoData.ldapUserSearchFilter = values.ldapUserSearchFilter;
}
if (form.isDirty("ldapTlsEnabled")) {
ssoData.ldapTlsEnabled = values.ldapTlsEnabled;
}
if (form.isDirty("ldapTlsCaCert")) {
ssoData.ldapTlsCaCert = values.ldapTlsCaCert;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("Display name")}
placeholder="e.g Company LDAP"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="LDAP Server URL"
description="URL of your LDAP server"
placeholder="ldap://ldap.example.com:389 or ldaps://ldap.example.com:636"
{...form.getInputProps("ldapUrl")}
/>
<TextInput
label="Bind DN"
description="Distinguished Name of the service account for searching"
placeholder="cn=admin,dc=example,dc=com"
{...form.getInputProps("ldapBindDn")}
/>
<TextInput
label="Bind Password"
description="Password for the service account"
type="password"
placeholder="••••••••"
{...form.getInputProps("ldapBindPassword")}
/>
<TextInput
label="Base DN"
description="Base DN where user searches will start"
placeholder="ou=users,dc=example,dc=com"
{...form.getInputProps("ldapBaseDn")}
/>
<TextInput
label="User Search Filter"
description="LDAP filter to find users. Use {{username}} as placeholder"
placeholder="(mail={{username}})"
{...form.getInputProps("ldapUserSearchFilter")}
/>
<Accordion variant="separated">
<Accordion.Item value="advanced">
<Accordion.Control icon={<IconInfoCircle size={20} />}>
{t("Advanced Settings")}
</Accordion.Control>
<Accordion.Panel>
<Stack>
<Group justify="space-between">
<div>
<Text size="sm">{t("Enable TLS/SSL")}</Text>
<Text size="xs" c="dimmed">
Use secure connection to LDAP server
</Text>
</div>
<Switch
className={classes.switch}
checked={form.values.ldapTlsEnabled}
{...form.getInputProps("ldapTlsEnabled")}
/>
</Group>
{form.values.ldapTlsEnabled && (
<Textarea
label="CA Certificate"
description="PEM-encoded CA certificate for TLS verification (optional)"
placeholder="-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----"
minRows={4}
{...form.getInputProps("ldapTlsCaCert")}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}
@@ -16,6 +16,7 @@ const ssoSchema = z.object({
oidcClientSecret: z.string().min(1, "Client secret is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
@@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
oidcClientSecret: provider.oidcClientSecret || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
@@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
@@ -78,7 +83,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
label={t("Display name")}
placeholder="e.g Google SSO"
data-autofocus
{...form.getInputProps("name")}
@@ -110,6 +115,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("oidcClientSecret")}
/>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
@@ -69,7 +69,7 @@ export default function SsoProviderList() {
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={500}>
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
@@ -104,7 +104,7 @@ export default function SsoProviderList() {
</Group>
</Table.Td>
<Table.Td>
<Badge color={"gray"} variant="light">
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
{provider.type.toUpperCase()}
</Badge>
</Table.Td>
@@ -133,6 +133,7 @@ export default function SsoProviderList() {
)}
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
@@ -168,6 +169,7 @@ export default function SsoProviderList() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -5,6 +5,8 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
import { useTranslation } from "react-i18next";
interface SsoModalProps {
opened: boolean;
@@ -17,6 +19,8 @@ export default function SsoProviderModal({
onClose,
provider,
}: SsoModalProps) {
const { t } = useTranslation();
if (!provider) {
return null;
}
@@ -24,7 +28,9 @@ export default function SsoProviderModal({
return (
<Modal
opened={opened}
title={`${provider.type.toUpperCase()} Configuration`}
title={t("{{ssoProviderType}} configuration", {
ssoProviderType: provider.type.toUpperCase(),
})}
onClose={onClose}
>
{provider.type === SSO_PROVIDER.SAML && (
@@ -38,6 +44,10 @@ export default function SsoProviderModal({
{provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.LDAP && (
<SsoLDAPForm provider={provider} onClose={onClose} />
)}
</Modal>
);
}
@@ -1,6 +1,7 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
@@ -26,6 +27,7 @@ const ssoSchema = z.object({
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
@@ -45,6 +47,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
samlCertificate: provider.samlCertificate || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
@@ -75,6 +78,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
@@ -86,7 +92,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
label={t("Display name")}
placeholder="e.g Azure Entra"
data-autofocus
{...form.getInputProps("name")}
@@ -123,6 +129,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("samlCertificate")}
/>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
+1
View File
@@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
OIDC = 'oidc',
SAML = 'saml',
GOOGLE = 'google',
LDAP = 'ldap',
}
@@ -0,0 +1,23 @@
import api from "@/lib/api-client.ts";
import { ILoginResponse } from "@/features/auth/types/auth.types.ts";
interface ILdapLogin {
username: string;
password: string;
providerId: string;
workspaceId: string;
}
export async function ldapLogin(data: ILdapLogin): Promise<ILoginResponse> {
const requestData = {
username: data.username,
password: data.password,
};
const response = await api.post<ILoginResponse>(
`/sso/ldap/${data.providerId}/login`,
requestData
);
return response.data;
}
@@ -9,8 +9,17 @@ export interface IAuthProvider {
oidcIssuer: string;
oidcClientId: string;
oidcClientSecret: string;
ldapUrl: string;
ldapBindDn: string;
ldapBindPassword: string;
ldapBaseDn: string;
ldapUserSearchFilter: string;
ldapUserAttributes: any;
ldapTlsEnabled: boolean;
ldapTlsCaCert: string;
allowSignup: boolean;
isEnabled: boolean;
groupSync: boolean;
creatorId: string;
workspaceId: string;
createdAt: Date;
@@ -0,0 +1,64 @@
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);
}
@@ -0,0 +1,9 @@
export {
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
uploadWorkspaceIcon,
removeAvatar,
removeSpaceIcon,
removeWorkspaceIcon,
} from "./attachment-service.ts";
@@ -0,0 +1,29 @@
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",
}
@@ -9,18 +9,21 @@ import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconMoodSmile,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@@ -56,6 +59,36 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor],
);
const setCalloutIcon = useCallback(
(emoji: any) => {
const emojiChar = emoji?.native || emoji?.emoji || emoji;
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutIcon(emojiChar)
.run();
},
[editor],
);
const removeCalloutIcon = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutIcon("")
.run();
}, [editor]);
const getCurrentIcon = () => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
const icon = parent?.node.attrs.icon;
return icon || null;
};
const currentIcon = getCurrentIcon();
return (
<BaseBubbleMenu
editor={editor}
@@ -130,6 +163,18 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
<IconCircleXFilled size={18} />
</ActionIcon>
</Tooltip>
<EmojiPicker
onEmojiSelect={setCalloutIcon}
removeEmojiAction={removeCalloutIcon}
readOnly={false}
icon={currentIcon || <IconMoodSmile size={18} />}
actionIconProps={{
size: "lg",
variant: "default",
c: undefined,
}}
/>
</ActionIcon.Group>
</BaseBubbleMenu>
);
@@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
export default function CalloutView(props: NodeViewProps) {
const { node } = props;
const { type } = node.attrs;
const { type, icon } = node.attrs;
return (
<NodeViewWrapper>
@@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
variant="light"
title=""
color={getCalloutColor(type)}
icon={getCalloutIcon(type)}
icon={getCalloutIcon(type, icon)}
p="xs"
classNames={{
message: classes.message,
@@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) {
);
}
function getCalloutIcon(type: CalloutType) {
function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
}
switch (type) {
case "info":
return <IconInfoCircleFilled />;
@@ -4,11 +4,7 @@ import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next";
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
});
import { useComputedColorScheme } from "@mantine/core";
interface MermaidViewProps {
props: NodeViewProps;
@@ -16,12 +12,22 @@ interface MermaidViewProps {
export default function MermaidView({ props }: MermaidViewProps) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme();
const { node } = props;
const [preview, setPreview] = useState<string>("");
// Update Mermaid config when theme changes.
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
theme: computedColorScheme === "light" ? "default" : "dark",
});
}, [computedColorScheme]);
// Re-render the diagram whenever the node content or theme changes.
useEffect(() => {
const id = `mermaid-${uuidv4()}`;
if (node.textContent.length > 0) {
mermaid
.render(id, node.textContent)
@@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
}
});
}
}, [node.textContent]);
}, [node.textContent, computedColorScheme]);
return (
<div
@@ -3,7 +3,6 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
export const handlePaste = (
@@ -34,7 +33,9 @@ export const handlePaste = (
return false;
}
createMentionAction(url, view, pos, creatorId);
const anchor = match[6]; // Extract anchor from the regex match
const urlWithoutAnchor = anchor ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchor);
return true;
}
@@ -17,7 +17,7 @@ import {
EventExit,
EventSave,
} from "react-drawio";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
@@ -145,6 +145,7 @@ export default function DrawioView(props: NodeViewProps) {
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
@@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
@@ -183,6 +183,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
@@ -0,0 +1,3 @@
.anchorScrollMargin {
scroll-margin-top: 95px;
}
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
export function useAnchorScroll(offset = 95, maxRetries = 10, retryDelay = 500) {
const location = useLocation();
const lastHash = useRef("");
useEffect(() => {
let retries = maxRetries;
const tryScroll = () => {
let el = document.getElementById(lastHash.current);
if (!el) {
const hash = lastHash.current;
if (hash.includes('-')) {
const parts = hash.split('-');
const possibleUid = parts[parts.length - 1];
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${possibleUid}`)) {
el = element as HTMLElement;
break;
}
}
}
if (!el) {
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${hash}`) || element.id === hash) {
el = element as HTMLElement;
break;
}
}
}
}
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
window.history.replaceState(null, "", `#${el.id}`);
} else if (retries > 0) {
retries--;
setTimeout(tryScroll, retryDelay);
}
};
if (location.hash) {
lastHash.current = location.hash.slice(1);
tryScroll();
}
}, [location, offset, maxRetries, retryDelay]);
}
@@ -9,6 +9,7 @@ export type LinkFn = (
view: EditorView,
pos: number,
creatorId: string,
anchor?: string,
) => void;
export interface InternalLinkOptions {
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId) => {
async (url: string, view, pos, creatorId, anchor) => {
const validated = validateFn(url, view);
if (!validated) return;
@@ -35,6 +36,7 @@ export const handleInternalLink =
entityId: page.id,
slugId: page.slugId,
creatorId: creatorId,
anchor: anchor,
});
if (!node) return;
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs;
const { label, entityType, entityId, slugId, anchor } = node.attrs;
const { spaceSlug } = useParams();
const { shareId } = useParams();
const {
@@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) {
shareId,
pageSlugId: slugId,
pageTitle: label,
anchor,
});
return (
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link}
fw={500}
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor)
}
underline="never"
className={classes.pageMentionLink}
@@ -17,8 +17,10 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconCalendar, IconAppWindow,
} from '@tabler/icons-react';
IconCalendar,
IconAppWindow,
IconSitemap,
} from "@tabler/icons-react";
import {
CommandProps,
SlashMenuGroupedItemsType,
@@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "Iframe embed",
description: "Embed any Iframe",
@@ -0,0 +1,95 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
}
interface ShouldShowProps {
state: any;
from?: number;
to?: number;
}
export const SubpagesMenu = React.memo(
({ editor }: SubpagesMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("subpages");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
.chain()
.focus()
.setNodeSelection(selection.from)
.deleteSelection()
.run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</BaseBubbleMenu>
);
},
);
export default SubpagesMenu;
@@ -0,0 +1,120 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({
pageId: currentPageId,
});
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
if (shareId && sharedSubpages) {
return sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]);
if (isLoading && !shareId) {
return null;
}
if (error && !shareId) {
return (
<NodeViewWrapper>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (subpages.length === 0) {
return (
<NodeViewWrapper>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper>
<div className={classes.container}>
<Stack gap={5}>
{subpages.map((page) => (
<Anchor
key={page.id}
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={styles.pageMentionText}>
{page?.title || t("untitled")}
</span>
</Anchor>
))}
</Stack>
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,9 @@
.container {
margin: 0;
padding-left: 4px;
user-select: none;
a {
border: none !important;
}
}
@@ -11,7 +11,7 @@ import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
@@ -38,6 +38,9 @@ import {
Embed,
SearchAndReplace,
Mention,
Subpages,
TableDndExtension,
HeadingAnchors,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -57,6 +60,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap";
@@ -75,6 +79,8 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz";
import UniqueID from "@tiptap/extension-unique-id";
import { generateEditorNodeId } from "../utils/nanoid";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -91,6 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
history: false,
heading: false,
dropcursor: {
width: 3,
color: "#70CFF8",
@@ -102,6 +109,7 @@ export const mainExtensions = [
},
},
}),
HeadingAnchors,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
@@ -162,12 +170,13 @@ export const mainExtensions = [
}),
CustomTable.configure({
resizable: true,
lastColumnResizable: false,
lastColumnResizable: true,
allowTableNodeSelection: true,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
MathInline.configure({
view: MathInlineView,
}),
@@ -212,6 +221,9 @@ export const mainExtensions = [
Embed.configure({
view: EmbedView,
}),
Subpages.configure({
view: SubpagesView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -234,6 +246,12 @@ export const mainExtensions = [
}
},
}).configure(),
UniqueID.configure({
types: ["heading"],
attributeName: "nodeId",
generateID: () => generateEditorNodeId(),
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -31,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
handleFileDrop,
handlePaste,
@@ -49,6 +50,8 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts';
import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
interface PageEditorProps {
pageId: string;
@@ -85,6 +88,7 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
// useAnchorScroll();
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
@@ -221,6 +225,10 @@ export default function PageEditor({
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
searchSpotlight.open();
return true;
}
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
@@ -375,7 +383,7 @@ export default function PageEditor({
}
return (
<div style={{ position: "relative" }}>
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@@ -391,6 +399,7 @@ export default function PageEditor({
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
@@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai/index";
import {
pageEditorAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { Editor } from "@tiptap/core";
import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
interface PageEditorProps {
title: string;
content: any;
pageId?: string;
}
export default function ReadonlyPageEditor({
title,
content,
pageId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
@@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
content={content}
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
editor.storage.pageId = pageId;
}
// @ts-ignore
setReadOnlyEditor(editor);
}
@@ -33,8 +33,8 @@
}
p {
margin-top: 0.65em;
margin-bottom: 0.65em;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
ul,
@@ -57,6 +57,7 @@
h5,
h6 {
line-height: 1.1;
margin-bottom: 10px;
}
code {
@@ -93,8 +94,12 @@
hr {
border: none;
border-top: 2px solid #ced4da;
margin: 2rem 0;
@mixin light {
border-top: 1px solid var(--mantine-color-gray-4);
}
@mixin dark {
border-top: 1px solid var(--mantine-color-dark-4);
}
&:hover {
cursor: pointer;
@@ -136,7 +141,7 @@
.selection,
*::selection {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-gray-7));
background-color: Highlight;
}
.comment-mark {
@@ -45,6 +45,10 @@
display: none;
pointer-events: none;
}
&[data-direction='horizontal'] {
rotate: 90deg;
}
}
.dark .drag-handle {
@@ -0,0 +1,79 @@
.heading-block {
position: relative;
scroll-margin-top: 80px;
}
.has-anchor {
position: relative;
}
.heading-anchor-wrapper {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
}
.heading-anchor-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--mantine-color-gray-5);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, color 0.2s ease;
outline: none;
}
.has-anchor:hover .heading-anchor-button {
opacity: 1;
}
.heading-anchor-button:hover {
color: var(--mantine-color-blue-6);
}
.heading-anchor-button.copied {
color: var(--mantine-color-green-6);
opacity: 1;
}
.heading-anchor-button svg {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.heading-anchor-button {
opacity: 0.3;
}
.has-anchor:hover .heading-anchor-button {
opacity: 0.7;
}
}
@media print {
.heading-anchor-wrapper {
display: none !important;
}
}
.ProseMirror .heading-anchor-button {
pointer-events: all;
}
/* Hide button when cursor is in the same heading */
.ProseMirror-focused .has-anchor.ProseMirror-selectednode .heading-anchor-button {
opacity: 0;
}
/* Always show on hover, regardless of focus state */
.has-anchor:hover .heading-anchor-button {
opacity: 1;
pointer-events: all;
}
@@ -12,3 +12,4 @@
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
@import "./heading-anchors.css";
@@ -1,11 +1,23 @@
@media print {
.mantine-AppShell-header,
.mantine-AppShell-navbar,
.mantine-AppShell-aside{
display: none !important;
}
.mantine-AppShell-header,
.mantine-AppShell-navbar,
.mantine-AppShell-aside,
.print-hide,
.drag-handle {
display: none !important;
}
.mantine-AppShell-main {
padding-top: 0 !important;
}
.mantine-AppShell-main {
padding-top: 0 !important;
min-height: auto !important;
}
.ProseMirror-selectednode {
outline: none !important;
box-shadow: none !important;
}
.tableWrapper {
overflow: hidden !important;
}
}
@@ -8,6 +8,16 @@
}
}
.table-dnd-preview {
padding: 0;
background-color: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(2px);
}
.table-dnd-drop-indicator {
background-color: #adf;
}
.ProseMirror {
table {
border-collapse: collapse;
@@ -118,3 +128,13 @@
}
}
}
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
}
.prosemirror-dropcursor-inline {
display: none;
}
}
@@ -10,6 +10,7 @@ ul[data-type="taskList"] {
display: flex;
> label {
padding-top: 0.2rem;
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
@@ -26,6 +26,7 @@ import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
export interface TitleEditorProps {
pageId: string;
@@ -86,6 +87,20 @@ export function TitleEditor({
content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
},
},
});
useEffect(() => {
@@ -193,7 +208,7 @@ export function TitleEditor({
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
@@ -0,0 +1,5 @@
import { customAlphabet } from "nanoid";
const slugIdAlphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12);
@@ -1,105 +0,0 @@
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import * as React from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
interface PageExportModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageExportModal({
pageId,
open,
onClose,
}: PageExportModalProps) {
const { t } = useTranslation();
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const handleExport = async () => {
try {
await exportPage({ pageId: pageId, format });
onClose();
} catch (err) {
notifications.show({
message: t("Export failed:") + err.response?.data.message,
color: "red",
});
console.error("export error", err);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport}>{t("Export")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
const { t } = useTranslation();
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label={t("Select export format")}
/>
);
}
@@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
@@ -84,6 +84,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
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 handleZipUpload = async (selectedFile: File, source: string) => {
@@ -116,6 +122,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
});
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) {
console.log("Failed to upload import file", err);
notifications.update({
@@ -243,6 +258,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree);
}
// Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current();
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -272,7 +291,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return (
<>
<SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept=".md" multiple>
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
{(props) => (
<Button
justify="start"
@@ -285,7 +304,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)}
</FileButton>
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
{(props) => (
<Button
justify="start"
@@ -301,6 +320,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip"
resetRef={notionFileRef}
>
{(props) => (
<Button
@@ -316,6 +336,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "confluence")}
accept="application/zip"
resetRef={confluenceFileRef}
>
{(props) => (
<Tooltip
@@ -352,6 +373,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "generic")}
accept="application/zip"
resetRef={zipFileRef}
>
{(props) => (
<Group justify="center">
+13 -6
View File
@@ -15,22 +15,29 @@ export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchor?: string,
): string => {
let url: string;
if (spaceName === undefined) {
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
return anchor ? `${url}#${anchor}` : url;
};
export const buildSharedPageUrl = (opts: {
shareId: string;
pageSlugId: string;
pageTitle?: string;
anchor?: string;
}): string => {
const { shareId, pageSlugId, pageTitle } = opts;
const { shareId, pageSlugId, pageTitle, anchor } = opts;
let url: string;
if (!shareId) {
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
return anchor ? `${url}#${anchor}` : url;
};
@@ -126,8 +126,9 @@ export function useUpdatePageMutation() {
export function useRemovePageMutation() {
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: () => {
onSuccess: (_, pageId) => {
notifications.show({ message: "Page moved to trash" });
invalidateOnDeletePage(pageId);
queryClient.invalidateQueries({
predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string),
@@ -251,6 +252,7 @@ export function useGetSidebarPagesQuery(
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
@@ -9,10 +9,11 @@ import {
SidebarPagesParams,
} from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data);
@@ -32,7 +32,7 @@ import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
export default function SpaceTrash() {
export default function Trash() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { page, setPage } = usePaginateAndSearch();
@@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
}
export interface SidebarPagesParams {
spaceId: string;
spaceId?: string;
pageId?: string;
page?: number; // pagination
}
@@ -79,6 +79,7 @@ export interface IExportPageParams {
pageId: string;
format: ExportFormat;
includeChildren?: boolean;
includeAttachments?: boolean;
}
export enum ExportFormat {
@@ -0,0 +1,136 @@
import React from "react";
import {
Group,
Center,
Text,
Badge,
ActionIcon,
Tooltip,
getDefaultZIndex,
} from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { Link } from "react-router-dom";
import { IconFile, IconDownload } from "@tabler/icons-react";
import { buildPageUrl } from "@/features/page/page.utils";
import { getPageIcon } from "@/lib";
import {
IAttachmentSearch,
IPageSearch,
} from "@/features/search/types/search.types";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch;
isAttachmentResult: boolean;
showSpace?: boolean;
}
export function SearchResultItem({
result,
isAttachmentResult,
showSpace,
}: SearchResultItemProps) {
const { t } = useTranslation();
if (isAttachmentResult) {
const attachmentResult = result as IAttachmentSearch;
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const downloadUrl = `/api/files/${attachmentResult.id}/${attachmentResult.fileName}`;
window.open(downloadUrl, "_blank");
};
return (
<Spotlight.Action
component={Link}
//@ts-ignore
to={buildPageUrl(
attachmentResult.space.slug,
attachmentResult.page.slugId,
attachmentResult.page.title,
)}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>
<IconFile size={16} />
</Center>
<div style={{ flex: 1 }}>
<Text>{attachmentResult.fileName}</Text>
<Text size="xs" opacity={0.6}>
{attachmentResult.space.name} {attachmentResult.page.title}
</Text>
{attachmentResult?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(attachmentResult.highlight, {
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
ALLOWED_ATTR: [],
}),
}}
/>
)}
</div>
<Tooltip
label={t("Download attachment")}
zIndex={getDefaultZIndex("max")}
withArrow
>
<ActionIcon variant="subtle" color="gray" onClick={handleDownload}>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Spotlight.Action>
);
} else {
const pageResult = result as IPageSearch;
return (
<Spotlight.Action
component={Link}
//@ts-ignore
to={buildPageUrl(
pageResult.space.slug,
pageResult.slugId,
pageResult.title,
)}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(pageResult?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{pageResult.title}</Text>
{showSpace && pageResult.space && (
<Badge variant="light" size="xs" color="gray">
{pageResult.space.name}
</Badge>
)}
{pageResult?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(pageResult.highlight, {
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
ALLOWED_ATTR: [],
}),
}}
/>
)}
</div>
</Group>
</Spotlight.Action>
);
}
}
@@ -0,0 +1,19 @@
.filtersContainer {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 8px 0;
scrollbar-width: thin;
}
.filterButton {
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
height: 32px;
padding: 0 12px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
&:hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
}
}
@@ -0,0 +1,253 @@
import React, { useState, useMemo, useEffect } from "react";
import {
Button,
Menu,
Text,
TextInput,
Divider,
Badge,
ScrollArea,
Avatar,
Group,
getDefaultZIndex,
} from "@mantine/core";
import {
IconChevronDown,
IconBuilding,
IconFileDescription,
IconSearch,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts";
interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void;
spaceId?: string;
}
export function SearchSpotlightFilters({
onFiltersChange,
spaceId,
}: SearchSpotlightFiltersProps) {
const { t } = useTranslation();
const { hasLicenseKey } = useLicense();
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
);
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page");
const { data: spacesData } = useGetSpacesQuery({
page: 1,
limit: 100,
query: debouncedSpaceQuery,
});
const selectedSpaceData = useMemo(() => {
if (!spacesData?.items || !selectedSpaceId) return null;
return spacesData.items.find((space) => space.id === selectedSpaceId);
}, [spacesData?.items, selectedSpaceId]);
const availableSpaces = useMemo(() => {
const spaces = spacesData?.items || [];
if (!selectedSpaceId) return spaces;
// Sort to put selected space first
return [...spaces].sort((a, b) => {
if (a.id === selectedSpaceId) return -1;
if (b.id === selectedSpaceId) return 1;
return 0;
});
}, [spacesData?.items, selectedSpaceId]);
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
spaceId: selectedSpaceId,
contentType,
});
}
}, []);
const contentTypeOptions = [
{ value: "page", label: t("Pages") },
{
value: "attachment",
label: t("Attachments"),
disabled: !isCloud() && !hasLicenseKey,
},
];
const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId);
if (onFiltersChange) {
onFiltersChange({
spaceId: spaceId,
contentType,
});
}
};
const handleFilterChange = (filterType: string, value: any) => {
let newSelectedSpaceId = selectedSpaceId;
let newContentType = contentType;
switch (filterType) {
case "spaceId":
newSelectedSpaceId = value;
setSelectedSpaceId(value);
break;
case "contentType":
newContentType = value;
setContentType(value);
break;
}
if (onFiltersChange) {
onFiltersChange({
spaceId: newSelectedSpaceId,
contentType: newContentType,
});
}
};
return (
<div className={classes.filtersContainer}>
<Menu
shadow="md"
width={250}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={spaceSearchQuery}
onChange={(e) => setSpaceSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => handleSpaceSelect(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!selectedSpaceId && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{availableSpaces.map((space) => (
<Menu.Item
key={space.id}
onClick={() => handleSpaceSelect(space.id)}
>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{selectedSpaceId === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<Menu
shadow="md"
width={220}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconFileDescription size={16} />}
className={classes.filterButton}
fw={500}
>
{contentType
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
: t("Type")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{contentTypeOptions.map((option) => (
<Menu.Item
key={option.value}
onClick={() =>
!option.disabled &&
contentType !== option.value &&
handleFilterChange("contentType", option.value)
}
disabled={option.disabled}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
{option.disabled && (
<Badge size="xs" mt={4}>
{t("Enterprise")}
</Badge>
)}
</div>
{contentType === option.value && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</div>
);
}
@@ -0,0 +1,104 @@
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import React, { useState, useMemo } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "../constants.ts";
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { SearchResultItem } from "./search-result-item.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const { hasLicenseKey } = useLicense();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState<{
spaceId?: string | null;
contentType?: string;
}>({
contentType: "page",
});
// Build unified search params
const searchParams = useMemo(() => {
const params: any = {
query: debouncedSearchQuery,
contentType: filters.contentType || "page", // Only used for frontend routing
};
// Handle space filtering - only pass spaceId if a specific space is selected
if (filters.spaceId) {
params.spaceId = filters.spaceId;
}
return params;
}, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
// Determine result type for rendering
const isAttachmentSearch =
filters.contentType === "attachment" && hasLicenseKey;
const resultItems = (searchResults || []).map((result) => (
<SearchResultItem
key={result.id}
result={result}
isAttachmentResult={isAttachmentSearch}
showSpace={!filters.spaceId}
/>
));
const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters);
};
return (
<>
<Spotlight.Root
size="xl"
maxHeight={600}
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<div
style={{
padding: "4px 16px",
}}
>
<SearchSpotlightFilters
onFiltersChange={handleFiltersChange}
spaceId={spaceId}
/>
</div>
<Spotlight.ActionsList>
{query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}
@@ -9,6 +9,7 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
import DOMPurify from "dompurify";
interface ShareSearchSpotlightProps {
shareId?: string;
@@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.highlight, {
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
ALLOWED_ATTR: []
}),
}}
/>
)}
</div>
@@ -0,0 +1,43 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchPage,
searchAttachments,
} from "@/features/search/services/search-service";
import {
IAttachmentSearch,
IPageSearch,
IPageSearchParams,
} from "@/features/search/types/search.types";
import { useLicense } from "@/ee/hooks/use-license";
import { isCloud } from "@/lib/config";
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
export interface UseUnifiedSearchParams extends IPageSearchParams {
contentType?: string;
}
export function useUnifiedSearch(
params: UseUnifiedSearchParams,
): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense();
const isAttachmentSearch =
params.contentType === "attachment" && (isCloud() || hasLicenseKey);
const searchType = isAttachmentSearch ? "attachment" : "page";
return useQuery({
queryKey: ["unified-search", searchType, params],
queryFn: async () => {
// Remove contentType from backend params since it's only used for frontend routing
const { contentType, ...backendParams } = params;
if (isAttachmentSearch) {
return await searchAttachments(backendParams);
} else {
return await searchPage(backendParams);
}
},
enabled: !!params.query,
});
}
@@ -1,15 +1,17 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchAttachments,
searchPage,
searchShare,
searchSuggestions,
} from "@/features/search/services/search-service";
} from '@/features/search/services/search-service';
import {
IAttachmentSearch,
IPageSearch,
IPageSearchParams,
ISuggestionResult,
SearchSuggestionParams,
} from "@/features/search/types/search.types";
} from '@/features/search/types/search.types';
export function usePageSearchQuery(
params: IPageSearchParams,
@@ -41,3 +43,13 @@ export function useShareSearchQuery(
enabled: !!params.query,
});
}
export function useAttachmentSearchQuery(
params: IPageSearchParams,
): UseQueryResult<IAttachmentSearch[], Error> {
return useQuery({
queryKey: ["attachment-search", params],
queryFn: () => searchAttachments(params),
enabled: !!params.query,
});
}
@@ -1,83 +0,0 @@
import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "./constants";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { data: searchResults } = usePageSearchQuery({
query: debouncedSearchQuery,
spaceId,
});
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
component={Link}
//@ts-ignore
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>
{page?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }}
/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight.Root
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}
@@ -1,10 +1,11 @@
import api from "@/lib/api-client";
import {
IAttachmentSearch,
IPageSearch,
IPageSearchParams,
ISuggestionResult,
SearchSuggestionParams,
} from "@/features/search/types/search.types";
} from '@/features/search/types/search.types';
export async function searchPage(
params: IPageSearchParams,
@@ -26,3 +27,10 @@ export async function searchShare(
const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data;
}
export async function searchAttachments(
params: IPageSearchParams,
): Promise<IAttachmentSearch[]> {
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
return req.data;
}
@@ -37,3 +37,25 @@ export interface IPageSearchParams {
spaceId?: string;
shareId?: string;
}
export interface IAttachmentSearch {
id: string;
fileName: string;
pageId: string;
creatorId: string;
createdAt: Date;
updatedAt: Date;
rank: string;
highlight: string;
space: {
id: string;
name: string;
slug: string;
icon: string;
};
page: {
id: string;
title: string;
slugId: string;
};
}
@@ -0,0 +1,6 @@
import { atom } from "jotai";
import { ISharedPageTree } from "@/features/share/types/share.types";
import { SharedPageTreeNode } from "@/features/share/utils";
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);

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