mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 22:53:08 +08:00
Compare commits
2 Commits
anchor-link
..
ai
| Author | SHA1 | Date | |
|---|---|---|---|
| bb83d12c8b | |||
| 0f29eb8842 |
+3
-11
@@ -2,18 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Docmost</title>
|
<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-->
|
<!--meta-tags-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.23.2",
|
"version": "0.22.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"katex": "0.16.22",
|
"katex": "0.16.22",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.11.0",
|
"mermaid": "^11.6.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "^1.255.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 562 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 509 B |
Binary file not shown.
|
Before Width: | Height: | Size: 881 B |
@@ -53,7 +53,6 @@
|
|||||||
"e.g Space for product team": "z.B. Bereich für das Produktteam",
|
"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",
|
"e.g Space for sales team to collaborate": "z.B. Bereich für das Vertriebsteam zur Zusammenarbeit",
|
||||||
"Edit": "Bearbeiten",
|
"Edit": "Bearbeiten",
|
||||||
"Read": "Lesen",
|
|
||||||
"Edit group": "Gruppe bearbeiten",
|
"Edit group": "Gruppe bearbeiten",
|
||||||
"Email": "E-Mail",
|
"Email": "E-Mail",
|
||||||
"Enter a strong password": "Geben Sie ein starkes Passwort ein",
|
"Enter a strong password": "Geben Sie ein starkes Passwort ein",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
|
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
|
||||||
"Deleted by": "Gelöscht von",
|
"Deleted by": "Gelöscht von",
|
||||||
"Deleted at": "Gelöscht am",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "e.g Space for product team",
|
"e.g Space for product team": "e.g Space for product team",
|
||||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
"Read": "Read",
|
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Edit group",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Enter a strong password": "Enter a strong password",
|
"Enter a strong password": "Enter a strong password",
|
||||||
@@ -234,7 +233,6 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
"Copy anchor link": "Copy anchor link",
|
|
||||||
"Copy to space": "Copy to space",
|
"Copy to space": "Copy to space",
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
@@ -405,7 +403,6 @@
|
|||||||
"Copy page": "Copy page",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Page copied successfully",
|
"Page copied successfully": "Page copied successfully",
|
||||||
"Anchor link copied": "Anchor link copied",
|
|
||||||
"Page duplicated successfully": "Page duplicated successfully",
|
"Page duplicated successfully": "Page duplicated successfully",
|
||||||
"Find": "Find",
|
"Find": "Find",
|
||||||
"Not found": "Not found",
|
"Not found": "Not found",
|
||||||
@@ -498,42 +495,5 @@
|
|||||||
"Page restored successfully": "Page restored successfully",
|
"Page restored successfully": "Page restored successfully",
|
||||||
"Deleted by": "Deleted by",
|
"Deleted by": "Deleted by",
|
||||||
"Deleted at": "Deleted at",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "ej: Espacio para el equipo de producto",
|
"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",
|
"e.g Space for sales team to collaborate": "ej: Espacio para que el equipo de ventas colabore",
|
||||||
"Edit": "Editar",
|
"Edit": "Editar",
|
||||||
"Read": "Leer",
|
|
||||||
"Edit group": "Editar grupo",
|
"Edit group": "Editar grupo",
|
||||||
"Email": "Correo electrónico",
|
"Email": "Correo electrónico",
|
||||||
"Enter a strong password": "Introduce una contraseña fuerte",
|
"Enter a strong password": "Introduce una contraseña fuerte",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Página restaurada con éxito",
|
"Page restored successfully": "Página restaurada con éxito",
|
||||||
"Deleted by": "Eliminado por",
|
"Deleted by": "Eliminado por",
|
||||||
"Deleted at": "Eliminado en",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "par ex. Espace pour l'équipe produit",
|
"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",
|
"e.g Space for sales team to collaborate": "par ex. Espace pour l'équipe de vente pour collaborer",
|
||||||
"Edit": "Modifier",
|
"Edit": "Modifier",
|
||||||
"Read": "Lire",
|
|
||||||
"Edit group": "Modifier groupe",
|
"Edit group": "Modifier groupe",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Enter a strong password": "Entrez un mot de passe fort",
|
"Enter a strong password": "Entrez un mot de passe fort",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Page restaurée avec succès",
|
"Page restored successfully": "Page restaurée avec succès",
|
||||||
"Deleted by": "Supprimé par",
|
"Deleted by": "Supprimé par",
|
||||||
"Deleted at": "Supprimé à",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "es. Spazio per il team di prodotto",
|
"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",
|
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
|
||||||
"Edit": "Modifica",
|
"Edit": "Modifica",
|
||||||
"Read": "Leggi",
|
|
||||||
"Edit group": "Modifica gruppo",
|
"Edit group": "Modifica gruppo",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Enter a strong password": "Inserisci una password sicura",
|
"Enter a strong password": "Inserisci una password sicura",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Pagina ripristinata con successo",
|
"Page restored successfully": "Pagina ripristinata con successo",
|
||||||
"Deleted by": "Eliminato da",
|
"Deleted by": "Eliminato da",
|
||||||
"Deleted at": "Eliminato il",
|
"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,7 +53,6 @@
|
|||||||
"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": "読む",
|
|
||||||
"Edit group": "グループを編集",
|
"Edit group": "グループを編集",
|
||||||
"Email": "メールアドレス",
|
"Email": "メールアドレス",
|
||||||
"Enter a strong password": "強力なパスワードを入力してください",
|
"Enter a strong password": "強力なパスワードを入力してください",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "ページが正常に復元されました",
|
"Page restored successfully": "ページが正常に復元されました",
|
||||||
"Deleted by": "削除者",
|
"Deleted by": "削除者",
|
||||||
"Deleted at": "削除日時",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "예: 제품 팀을 위한 Space",
|
"e.g Space for product team": "예: 제품 팀을 위한 Space",
|
||||||
"e.g Space for sales team to collaborate": "예: 영업 팀의 Space",
|
"e.g Space for sales team to collaborate": "예: 영업 팀의 Space",
|
||||||
"Edit": "편집",
|
"Edit": "편집",
|
||||||
"Read": "읽기",
|
|
||||||
"Edit group": "팀 편집",
|
"Edit group": "팀 편집",
|
||||||
"Email": "이메일",
|
"Email": "이메일",
|
||||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "페이지가 성공적으로 복구되었습니다",
|
"Page restored successfully": "페이지가 성공적으로 복구되었습니다",
|
||||||
"Deleted by": "삭제자",
|
"Deleted by": "삭제자",
|
||||||
"Deleted at": "삭제 시간",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "bijv. Ruimte voor productteam",
|
"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",
|
"e.g Space for sales team to collaborate": "bijv. Ruimte voor verkoopteam om samen te werken",
|
||||||
"Edit": "Bewerken",
|
"Edit": "Bewerken",
|
||||||
"Read": "Lezen",
|
|
||||||
"Edit group": "Groep bewerken",
|
"Edit group": "Groep bewerken",
|
||||||
"Email": "E-mailadres",
|
"Email": "E-mailadres",
|
||||||
"Enter a strong password": "Voer een sterk wachtwoord in",
|
"Enter a strong password": "Voer een sterk wachtwoord in",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Pagina succesvol hersteld",
|
"Page restored successfully": "Pagina succesvol hersteld",
|
||||||
"Deleted by": "Verwijderd door",
|
"Deleted by": "Verwijderd door",
|
||||||
"Deleted at": "Verwijderd op",
|
"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,7 +53,6 @@
|
|||||||
"e.g Space for product team": "ex.: Espaço para a equipe de produto",
|
"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",
|
"e.g Space for sales team to collaborate": "ex.: Espaço para a equipe de vendas colaborar",
|
||||||
"Edit": "Editar",
|
"Edit": "Editar",
|
||||||
"Read": "Ler",
|
|
||||||
"Edit group": "Editar grupo",
|
"Edit group": "Editar grupo",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Enter a strong password": "Insira uma senha forte",
|
"Enter a strong password": "Insira uma senha forte",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Página restaurada com sucesso",
|
"Page restored successfully": "Página restaurada com sucesso",
|
||||||
"Deleted by": "Excluído por",
|
"Deleted by": "Excluído por",
|
||||||
"Deleted at": "Excluído em",
|
"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,7 +53,6 @@
|
|||||||
"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": "Читать",
|
|
||||||
"Edit group": "Редактировать группу",
|
"Edit group": "Редактировать группу",
|
||||||
"Email": "Электронная почта",
|
"Email": "Электронная почта",
|
||||||
"Enter a strong password": "Введите надёжный пароль",
|
"Enter a strong password": "Введите надёжный пароль",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Страница успешно восстановлена",
|
"Page restored successfully": "Страница успешно восстановлена",
|
||||||
"Deleted by": "Удалено пользователем",
|
"Deleted by": "Удалено пользователем",
|
||||||
"Deleted at": "Удалено в",
|
"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,7 +53,6 @@
|
|||||||
"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": "Читати",
|
|
||||||
"Edit group": "Редагувати групу",
|
"Edit group": "Редагувати групу",
|
||||||
"Email": "Електронна пошта",
|
"Email": "Електронна пошта",
|
||||||
"Enter a strong password": "Введіть надійний пароль",
|
"Enter a strong password": "Введіть надійний пароль",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "Сторінку успішно відновлено",
|
"Page restored successfully": "Сторінку успішно відновлено",
|
||||||
"Deleted by": "Видалено",
|
"Deleted by": "Видалено",
|
||||||
"Deleted at": "Видалено о",
|
"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,7 +53,6 @@
|
|||||||
"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": "阅读",
|
|
||||||
"Edit group": "编辑群组",
|
"Edit group": "编辑群组",
|
||||||
"Email": "电子邮箱",
|
"Email": "电子邮箱",
|
||||||
"Enter a strong password": "输入一个强密码",
|
"Enter a strong password": "输入一个强密码",
|
||||||
@@ -496,36 +495,5 @@
|
|||||||
"Page restored successfully": "页面恢复成功",
|
"Page restored successfully": "页面恢复成功",
|
||||||
"Deleted by": "删除人",
|
"Deleted by": "删除人",
|
||||||
"Deleted at": "删除时间",
|
"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}} 配置"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import React, { useRef } from "react";
|
|
||||||
import { Menu, Box, Loader } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { IconTrash, IconUpload } from "@tabler/icons-react";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
|
|
||||||
interface AvatarUploaderProps {
|
|
||||||
currentImageUrl?: string | null;
|
|
||||||
fallbackName?: string;
|
|
||||||
radius?: string | number;
|
|
||||||
size?: string | number;
|
|
||||||
variant?: string;
|
|
||||||
type: AvatarIconType;
|
|
||||||
onUpload: (file: File) => Promise<void>;
|
|
||||||
onRemove: () => Promise<void>;
|
|
||||||
isLoading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AvatarUploader({
|
|
||||||
currentImageUrl,
|
|
||||||
fallbackName,
|
|
||||||
radius,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
type,
|
|
||||||
onUpload,
|
|
||||||
onRemove,
|
|
||||||
isLoading = false,
|
|
||||||
disabled = false,
|
|
||||||
}: AvatarUploaderProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleFileInputChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file || disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (max 10MB)
|
|
||||||
const maxSizeInBytes = 10 * 1024 * 1024;
|
|
||||||
if (file.size > maxSizeInBytes) {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Image exceeds 10MB limit."),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
// Reset the input
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onUpload(file);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Failed to upload image"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the input so the same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.click();
|
|
||||||
} else {
|
|
||||||
console.error("File input ref is null!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = async () => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onRemove();
|
|
||||||
notifications.show({
|
|
||||||
message: t("Image removed successfully"),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Failed to remove image"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
|
|
||||||
<Menu.Target>
|
|
||||||
<Box style={{ position: "relative", display: "inline-block" }}>
|
|
||||||
<CustomAvatar
|
|
||||||
component="button"
|
|
||||||
size={size}
|
|
||||||
avatarUrl={currentImageUrl}
|
|
||||||
name={fallbackName}
|
|
||||||
style={{
|
|
||||||
cursor: disabled || isLoading ? "default" : "pointer",
|
|
||||||
opacity: isLoading ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
radius={radius}
|
|
||||||
variant={variant}
|
|
||||||
type={type}
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconUpload size={16} />}
|
|
||||||
disabled={isLoading || disabled}
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
>
|
|
||||||
{t("Upload image")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
{currentImageUrl && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
color="red"
|
|
||||||
onClick={handleRemove}
|
|
||||||
disabled={isLoading || disabled}
|
|
||||||
>
|
|
||||||
{t("Remove image")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,6 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
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" }];
|
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||||
|
|
||||||
@@ -87,15 +79,6 @@ export function AppHeader() {
|
|||||||
</Group>
|
</Group>
|
||||||
</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">
|
<Group px={"xl"} wrap="nowrap">
|
||||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
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() {
|
export default function Layout() {
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
{isCloud() && <PosthogUser />}
|
{isCloud() && <PosthogUser />}
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Text,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
|
Text,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IconBrush,
|
IconBrush,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
|
IconChevronRight,
|
||||||
IconDeviceDesktop,
|
IconDeviceDesktop,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
@@ -25,7 +26,6 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
|||||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
|
||||||
|
|
||||||
export default function TopMenu() {
|
export default function TopMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -50,7 +50,6 @@ export default function TopMenu() {
|
|||||||
name={workspace?.name}
|
name={workspace?.name}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="sm"
|
size="sm"
|
||||||
type={AvatarIconType.WORKSPACE_ICON}
|
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
{workspace?.name}
|
{workspace?.name}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function AppVersion() {
|
|||||||
href="https://github.com/docmost/docmost/releases"
|
href="https://github.com/docmost/docmost/releases"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
|
v{APP_VERSION}
|
||||||
</Text>
|
</Text>
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar } from "@mantine/core";
|
||||||
import { getAvatarUrl } from "@/lib/config.ts";
|
import { getAvatarUrl } from "@/lib/config.ts";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
|
||||||
|
|
||||||
interface CustomAvatarProps {
|
interface CustomAvatarProps {
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
@@ -12,15 +11,13 @@ interface CustomAvatarProps {
|
|||||||
variant?: string;
|
variant?: string;
|
||||||
style?: any;
|
style?: any;
|
||||||
component?: any;
|
component?: any;
|
||||||
type?: AvatarIconType;
|
|
||||||
mt?: string | number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
const avatarLink = getAvatarUrl(avatarUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -15,11 +15,6 @@ export interface EmojiPickerInterface {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
removeEmojiAction: () => void;
|
removeEmojiAction: () => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
actionIconProps?: {
|
|
||||||
size?: string;
|
|
||||||
variant?: string;
|
|
||||||
c?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPicker({
|
function EmojiPicker({
|
||||||
@@ -27,7 +22,6 @@ function EmojiPicker({
|
|||||||
icon,
|
icon,
|
||||||
removeEmojiAction,
|
removeEmojiAction,
|
||||||
readOnly,
|
readOnly,
|
||||||
actionIconProps,
|
|
||||||
}: EmojiPickerInterface) {
|
}: EmojiPickerInterface) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, handlers] = useDisclosure(false);
|
const [opened, handlers] = useDisclosure(false);
|
||||||
@@ -70,12 +64,7 @@ function EmojiPicker({
|
|||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
>
|
>
|
||||||
<Popover.Target ref={setTarget}>
|
<Popover.Target ref={setTarget}>
|
||||||
<ActionIcon
|
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
||||||
c={actionIconProps?.c || "gray"}
|
|
||||||
variant={actionIconProps?.variant || "transparent"}
|
|
||||||
size={actionIconProps?.size}
|
|
||||||
onClick={handlers.toggle}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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,61 @@
|
|||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
||||||
|
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
|
||||||
|
|
||||||
|
export function useAiStream() {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const mutation = useAiGenerateStreamMutation();
|
||||||
|
|
||||||
|
const startStream = useCallback(
|
||||||
|
async (data: AiGenerateDto) => {
|
||||||
|
setContent("");
|
||||||
|
setIsStreaming(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = await mutation.mutateAsync({
|
||||||
|
...data,
|
||||||
|
onChunk: (chunk) => {
|
||||||
|
setContent((prev) => prev + chunk.content);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("AI stream error:", error);
|
||||||
|
setIsStreaming(false);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setIsStreaming(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start stream:", error);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetContent = useCallback(() => {
|
||||||
|
setContent("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
isStreaming,
|
||||||
|
startStream,
|
||||||
|
stopStream,
|
||||||
|
resetContent,
|
||||||
|
isLoading: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
UseMutationResult,
|
||||||
|
useQuery,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
generateAiContent,
|
||||||
|
generateAiContentStream,
|
||||||
|
getAiConfig,
|
||||||
|
} from "@/ee/ai/services/ai-service.ts";
|
||||||
|
import {
|
||||||
|
AiConfigResponse,
|
||||||
|
AiContentResponse,
|
||||||
|
AiGenerateDto,
|
||||||
|
AiStreamChunk,
|
||||||
|
AiStreamError,
|
||||||
|
} from "@/ee/ai/types/ai.types.ts";
|
||||||
|
|
||||||
|
export function useAiGenerateMutation(): UseMutationResult<
|
||||||
|
AiContentResponse,
|
||||||
|
Error,
|
||||||
|
AiGenerateDto
|
||||||
|
> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamCallbacks {
|
||||||
|
onChunk: (chunk: AiStreamChunk) => void;
|
||||||
|
onError?: (error: AiStreamError) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiGenerateStreamMutation(): UseMutationResult<
|
||||||
|
AbortController,
|
||||||
|
Error,
|
||||||
|
AiGenerateDto & StreamCallbacks
|
||||||
|
> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
|
||||||
|
generateAiContentStream(data, onChunk, onError, onComplete),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import api from "@/lib/api-client.ts";
|
||||||
|
import {
|
||||||
|
AiGenerateDto,
|
||||||
|
AiContentResponse,
|
||||||
|
AiStreamChunk,
|
||||||
|
AiStreamError,
|
||||||
|
} from "@/ee/ai/types/ai.types.ts";
|
||||||
|
|
||||||
|
export async function generateAiContent(
|
||||||
|
data: AiGenerateDto,
|
||||||
|
): Promise<AiContentResponse> {
|
||||||
|
const req = await api.post<AiContentResponse>("/ai/generate", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAiContentStream(
|
||||||
|
data: AiGenerateDto,
|
||||||
|
onChunk: (chunk: AiStreamChunk) => void,
|
||||||
|
onError?: (error: AiStreamError) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): Promise<AbortController> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/ai/generate/stream", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal: abortController.signal,
|
||||||
|
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const processStream = async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.error) {
|
||||||
|
onError?.(parsed);
|
||||||
|
} else {
|
||||||
|
onChunk(parsed);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors for incomplete chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
onError?.({ error: error.message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processStream();
|
||||||
|
} catch (error) {
|
||||||
|
onError?.({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return abortController;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export enum AiAction {
|
||||||
|
IMPROVE_WRITING = "improve_writing",
|
||||||
|
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
|
||||||
|
MAKE_SHORTER = "make_shorter",
|
||||||
|
MAKE_LONGER = "make_longer",
|
||||||
|
SIMPLIFY = "simplify",
|
||||||
|
CHANGE_TONE = "change_tone",
|
||||||
|
SUMMARIZE = "summarize",
|
||||||
|
CONTINUE_WRITING = "continue_writing",
|
||||||
|
TRANSLATE = "translate",
|
||||||
|
CUSTOM = "custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiGenerateDto {
|
||||||
|
action?: AiAction;
|
||||||
|
content: string;
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiContentResponse {
|
||||||
|
content: string;
|
||||||
|
usage?: {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiConfigResponse {
|
||||||
|
configured: boolean;
|
||||||
|
availableActions: AiAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiStreamChunk {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiStreamError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +1,29 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { Button, Divider, Stack } from "@mantine/core";
|
import { Button, Divider, Stack } from "@mantine/core";
|
||||||
import { IconLock, IconServer } from "@tabler/icons-react";
|
import { IconLock } from "@tabler/icons-react";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
|
|
||||||
|
|
||||||
export default function SsoLogin() {
|
export default function SsoLogin() {
|
||||||
const { data, isLoading } = useWorkspacePublicDataQuery();
|
const { data, isLoading } = useWorkspacePublicDataQuery();
|
||||||
const [ldapModalOpened, setLdapModalOpened] = useState(false);
|
|
||||||
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
|
|
||||||
|
|
||||||
if (!data?.authProviders || data?.authProviders?.length === 0) {
|
if (!data?.authProviders || data?.authProviders?.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSsoLogin = (provider: IAuthProvider) => {
|
const handleSsoLogin = (provider: IAuthProvider) => {
|
||||||
if (provider.type === SSO_PROVIDER.LDAP) {
|
window.location.href = buildSsoLoginUrl({
|
||||||
// Open modal for LDAP instead of redirecting
|
providerId: provider.id,
|
||||||
setSelectedLdapProvider(provider);
|
type: provider.type,
|
||||||
setLdapModalOpened(true);
|
workspaceId: data.id,
|
||||||
} 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedLdapProvider && (
|
|
||||||
<LdapLoginModal
|
|
||||||
opened={ldapModalOpened}
|
|
||||||
onClose={() => {
|
|
||||||
setLdapModalOpened(false);
|
|
||||||
setSelectedLdapProvider(null);
|
|
||||||
}}
|
|
||||||
provider={selectedLdapProvider}
|
|
||||||
workspaceId={data.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isCloud() || data.hasLicenseKey) && (
|
{(isCloud() || data.hasLicenseKey) && (
|
||||||
<>
|
<>
|
||||||
<Stack align="stretch" justify="center" gap="sm">
|
<Stack align="stretch" justify="center" gap="sm">
|
||||||
@@ -64,7 +31,13 @@ export default function SsoLogin() {
|
|||||||
<div key={provider.id}>
|
<div key={provider.id}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleSsoLogin(provider)}
|
onClick={() => handleSsoLogin(provider)}
|
||||||
leftSection={getProviderIcon(provider)}
|
leftSection={
|
||||||
|
provider.type === SSO_PROVIDER.GOOGLE ? (
|
||||||
|
<GoogleIcon size={16} />
|
||||||
|
) : (
|
||||||
|
<IconLock size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export function MfaBackupCodeInput({
|
|||||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
||||||
error={error}
|
error={error}
|
||||||
autoFocus
|
autoFocus
|
||||||
data-autofocus
|
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -25,30 +25,23 @@ import { regenerateBackupCodes } from "@/ee/mfa";
|
|||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
|
||||||
|
|
||||||
interface MfaBackupCodesModalProps {
|
interface MfaBackupCodesModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||||
|
});
|
||||||
|
|
||||||
export function MfaBackupCodesModal({
|
export function MfaBackupCodesModal({
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
}: MfaBackupCodesModalProps) {
|
}: MfaBackupCodesModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: currentUser } = useCurrentUser();
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
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({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@@ -58,7 +51,7 @@ export function MfaBackupCodesModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const regenerateMutation = useMutation({
|
const regenerateMutation = useMutation({
|
||||||
mutationFn: (data: { confirmPassword?: string }) =>
|
mutationFn: (data: { confirmPassword: string }) =>
|
||||||
regenerateBackupCodes(data),
|
regenerateBackupCodes(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setBackupCodes(data.backupCodes);
|
setBackupCodes(data.backupCodes);
|
||||||
@@ -80,12 +73,8 @@ export function MfaBackupCodesModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRegenerate = (values: { confirmPassword?: string }) => {
|
const handleRegenerate = (values: { confirmPassword: string }) => {
|
||||||
// Only send confirmPassword if it's required (non-SSO users)
|
regenerateMutation.mutate(values);
|
||||||
const payload = requiresPassword
|
|
||||||
? { confirmPassword: values.confirmPassword }
|
|
||||||
: {};
|
|
||||||
regenerateMutation.mutate(payload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -125,16 +114,12 @@ export function MfaBackupCodesModal({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{requiresPassword && (
|
<PasswordInput
|
||||||
<PasswordInput
|
label={t("Confirm password")}
|
||||||
label={t("Confirm password")}
|
placeholder={t("Enter your password")}
|
||||||
placeholder={t("Enter your password")}
|
variant="filled"
|
||||||
variant="filled"
|
{...form.getInputProps("confirmPassword")}
|
||||||
{...form.getInputProps("confirmPassword")}
|
/>
|
||||||
autoFocus
|
|
||||||
data-autofocus
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export function MfaChallenge() {
|
|||||||
length={6}
|
length={6}
|
||||||
type="number"
|
type="number"
|
||||||
autoFocus
|
autoFocus
|
||||||
data-autofocus
|
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
{...form.getInputProps("code")}
|
{...form.getInputProps("code")}
|
||||||
error={!!form.errors.code}
|
error={!!form.errors.code}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { disableMfa } from "@/ee/mfa";
|
import { disableMfa } from "@/ee/mfa";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
|
||||||
|
|
||||||
interface MfaDisableModalProps {
|
interface MfaDisableModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -23,22 +22,16 @@ interface MfaDisableModalProps {
|
|||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||||
|
});
|
||||||
|
|
||||||
export function MfaDisableModal({
|
export function MfaDisableModal({
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: MfaDisableModalProps) {
|
}: MfaDisableModalProps) {
|
||||||
const { t } = useTranslation();
|
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({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@@ -61,12 +54,8 @@ export function MfaDisableModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: { confirmPassword?: string }) => {
|
const handleSubmit = async (values: { confirmPassword: string }) => {
|
||||||
// Only send confirmPassword if it's required (non-SSO users)
|
await disableMutation.mutateAsync(values);
|
||||||
const payload = requiresPassword
|
|
||||||
? { confirmPassword: values.confirmPassword }
|
|
||||||
: {};
|
|
||||||
await disableMutation.mutateAsync(payload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -96,23 +85,18 @@ export function MfaDisableModal({
|
|||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{requiresPassword && (
|
<Text size="sm">
|
||||||
<>
|
{t(
|
||||||
<Text size="sm">
|
"Please enter your password to disable two-factor authentication:",
|
||||||
{t(
|
)}
|
||||||
"Please enter your password to disable two-factor authentication:",
|
</Text>
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("Password")}
|
label={t("Password")}
|
||||||
placeholder={t("Enter your password")}
|
placeholder={t("Enter your password")}
|
||||||
{...form.getInputProps("confirmPassword")}
|
{...form.getInputProps("confirmPassword")}
|
||||||
autoFocus
|
autoFocus
|
||||||
data-autofocus
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { MfaDisableModal } from "@/ee/mfa";
|
|||||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
import { MfaBackupCodesModal } from "@/ee/mfa";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||||
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
|
|
||||||
|
|
||||||
export function MfaSettings() {
|
export function MfaSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -54,8 +53,8 @@ export function MfaSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveSettingsRow>
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<ResponsiveSettingsContent>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Text size="md">{t("2-step verification")}</Text>
|
<Text size="md">{t("2-step verification")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{!isMfaEnabled
|
{!isMfaEnabled
|
||||||
@@ -64,46 +63,44 @@ export function MfaSettings() {
|
|||||||
)
|
)
|
||||||
: t("Two-factor authentication is active on your account.")}
|
: t("Two-factor authentication is active on your account.")}
|
||||||
</Text>
|
</Text>
|
||||||
</ResponsiveSettingsContent>
|
</div>
|
||||||
|
|
||||||
<ResponsiveSettingsControl>
|
{!isMfaEnabled ? (
|
||||||
{!isMfaEnabled ? (
|
<Tooltip
|
||||||
<Tooltip
|
label={t("Available in enterprise edition")}
|
||||||
label={t("Available in enterprise edition")}
|
disabled={canUseMfa}
|
||||||
disabled={canUseMfa}
|
>
|
||||||
|
<Button
|
||||||
|
disabled={!canUseMfa}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setSetupModalOpen(true)}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
<Button
|
{t("Add 2FA method")}
|
||||||
disabled={!canUseMfa}
|
</Button>
|
||||||
variant="default"
|
</Tooltip>
|
||||||
onClick={() => setSetupModalOpen(true)}
|
) : (
|
||||||
style={{ whiteSpace: "nowrap" }}
|
<Group gap="sm" wrap="nowrap">
|
||||||
>
|
<Button
|
||||||
{t("Add 2FA method")}
|
variant="default"
|
||||||
</Button>
|
size="sm"
|
||||||
</Tooltip>
|
onClick={() => setBackupCodesModalOpen(true)}
|
||||||
) : (
|
style={{ whiteSpace: "nowrap" }}
|
||||||
<Group gap="sm" wrap="nowrap">
|
>
|
||||||
<Button
|
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
|
||||||
variant="default"
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => setBackupCodesModalOpen(true)}
|
variant="default"
|
||||||
style={{ whiteSpace: "nowrap" }}
|
size="sm"
|
||||||
>
|
color="red"
|
||||||
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
|
onClick={() => setDisableModalOpen(true)}
|
||||||
</Button>
|
style={{ whiteSpace: "nowrap" }}
|
||||||
<Button
|
>
|
||||||
variant="default"
|
{t("Disable")}
|
||||||
size="sm"
|
</Button>
|
||||||
color="red"
|
</Group>
|
||||||
onClick={() => setDisableModalOpen(true)}
|
)}
|
||||||
style={{ whiteSpace: "nowrap" }}
|
</Group>
|
||||||
>
|
|
||||||
{t("Disable")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</ResponsiveSettingsControl>
|
|
||||||
</ResponsiveSettingsRow>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
<MfaSetupModal
|
||||||
opened={setupModalOpen}
|
opened={setupModalOpen}
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ export function MfaSetupModal({
|
|||||||
length={6}
|
length={6}
|
||||||
type="number"
|
type="number"
|
||||||
autoFocus
|
autoFocus
|
||||||
data-autofocus
|
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
{...form.getInputProps("verificationCode")}
|
{...form.getInputProps("verificationCode")}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function disableMfa(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateBackupCodes(data: {
|
export async function regenerateBackupCodes(data: {
|
||||||
confirmPassword?: string;
|
confirmPassword: string;
|
||||||
}): Promise<MfaBackupCodesResponse> {
|
}): Promise<MfaBackupCodesResponse> {
|
||||||
const req = await api.post<MfaBackupCodesResponse>(
|
const req = await api.post<MfaBackupCodesResponse>(
|
||||||
"/mfa/generate-backup-codes",
|
"/mfa/generate-backup-codes",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface MfaEnableResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaDisableRequest {
|
export interface MfaDisableRequest {
|
||||||
confirmPassword?: string;
|
confirmPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaBackupCodesResponse {
|
export interface MfaBackupCodesResponse {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Text, TagsInput } from "@mantine/core";
|
import { Button, Text, TagsInput } from "@mantine/core";
|
||||||
@@ -55,11 +54,9 @@ export default function AllowedDomains() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">{t("Allowed email domains")}</Text>
|
<Text size="md">Allowed email domains</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
Only users with email addresses from these domains can signup via SSO.
|
||||||
"Only users with email addresses from these domains can signup via SSO.",
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { Button, Menu, Group } from "@mantine/core";
|
import { Button, Menu, Group } from "@mantine/core";
|
||||||
import { IconChevronDown, IconLock, IconServer } from "@tabler/icons-react";
|
import { IconChevronDown, IconLock } from "@tabler/icons-react";
|
||||||
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
@@ -40,19 +40,6 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
|
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
|
||||||
@@ -84,13 +71,6 @@ export default function CreateSsoProvider() {
|
|||||||
>
|
>
|
||||||
OpenID (OIDC)
|
OpenID (OIDC)
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
onClick={handleCreateLDAP}
|
|
||||||
leftSection={<IconServer size={16} />}
|
|
||||||
>
|
|
||||||
LDAP / Active Directory
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
||||||
import classes from "@/ee/security/components/sso.module.css";
|
import classes from "@/ee/security/components/sso.module.css";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
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,7 +16,6 @@ const ssoSchema = z.object({
|
|||||||
oidcClientSecret: z.string().min(1, "Client secret is required"),
|
oidcClientSecret: z.string().min(1, "Client secret is required"),
|
||||||
isEnabled: z.boolean(),
|
isEnabled: z.boolean(),
|
||||||
allowSignup: z.boolean(),
|
allowSignup: z.boolean(),
|
||||||
groupSync: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||||
@@ -37,7 +36,6 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
oidcClientSecret: provider.oidcClientSecret || "",
|
oidcClientSecret: provider.oidcClientSecret || "",
|
||||||
isEnabled: provider.isEnabled,
|
isEnabled: provider.isEnabled,
|
||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
groupSync: provider.groupSync || false,
|
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zodResolver(ssoSchema),
|
||||||
});
|
});
|
||||||
@@ -69,9 +67,6 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
if (form.isDirty("allowSignup")) {
|
if (form.isDirty("allowSignup")) {
|
||||||
ssoData.allowSignup = values.allowSignup;
|
ssoData.allowSignup = values.allowSignup;
|
||||||
}
|
}
|
||||||
if (form.isDirty("groupSync")) {
|
|
||||||
ssoData.groupSync = values.groupSync;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||||
form.resetDirty();
|
form.resetDirty();
|
||||||
@@ -83,7 +78,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("Display name")}
|
label="Display name"
|
||||||
placeholder="e.g Google SSO"
|
placeholder="e.g Google SSO"
|
||||||
data-autofocus
|
data-autofocus
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
@@ -115,15 +110,6 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
{...form.getInputProps("oidcClientSecret")}
|
{...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">
|
<Group justify="space-between">
|
||||||
<div>{t("Allow signup")}</div>
|
<div>{t("Allow signup")}</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function SsoProviderList() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card shadow="sm" radius="sm">
|
<Card shadow="sm" radius="sm">
|
||||||
<Table.ScrollContainer minWidth={600}>
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table verticalSpacing="sm">
|
<Table verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -104,7 +104,7 @@ export default function SsoProviderList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
|
<Badge color={"gray"} variant="light">
|
||||||
{provider.type.toUpperCase()}
|
{provider.type.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -133,7 +133,6 @@ export default function SsoProviderList() {
|
|||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -169,7 +168,6 @@ export default function SsoProviderList() {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
|
|||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||||
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
|
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
|
||||||
import { SsoGoogleForm } from "@/ee/security/components/sso-google-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 {
|
interface SsoModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -19,8 +17,6 @@ export default function SsoProviderModal({
|
|||||||
onClose,
|
onClose,
|
||||||
provider,
|
provider,
|
||||||
}: SsoModalProps) {
|
}: SsoModalProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -28,9 +24,7 @@ export default function SsoProviderModal({
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
title={t("{{ssoProviderType}} configuration", {
|
title={`${provider.type.toUpperCase()} Configuration`}
|
||||||
ssoProviderType: provider.type.toUpperCase(),
|
|
||||||
})}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{provider.type === SSO_PROVIDER.SAML && (
|
{provider.type === SSO_PROVIDER.SAML && (
|
||||||
@@ -44,10 +38,6 @@ export default function SsoProviderModal({
|
|||||||
{provider.type === SSO_PROVIDER.GOOGLE && (
|
{provider.type === SSO_PROVIDER.GOOGLE && (
|
||||||
<SsoGoogleForm provider={provider} onClose={onClose} />
|
<SsoGoogleForm provider={provider} onClose={onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{provider.type === SSO_PROVIDER.LDAP && (
|
|
||||||
<SsoLDAPForm provider={provider} onClose={onClose} />
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,7 +26,6 @@ const ssoSchema = z.object({
|
|||||||
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
||||||
isEnabled: z.boolean(),
|
isEnabled: z.boolean(),
|
||||||
allowSignup: z.boolean(),
|
allowSignup: z.boolean(),
|
||||||
groupSync: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||||
@@ -47,7 +45,6 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
samlCertificate: provider.samlCertificate || "",
|
samlCertificate: provider.samlCertificate || "",
|
||||||
isEnabled: provider.isEnabled,
|
isEnabled: provider.isEnabled,
|
||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
groupSync: provider.groupSync || false,
|
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zodResolver(ssoSchema),
|
||||||
});
|
});
|
||||||
@@ -78,9 +75,6 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
if (form.isDirty("allowSignup")) {
|
if (form.isDirty("allowSignup")) {
|
||||||
ssoData.allowSignup = values.allowSignup;
|
ssoData.allowSignup = values.allowSignup;
|
||||||
}
|
}
|
||||||
if (form.isDirty("groupSync")) {
|
|
||||||
ssoData.groupSync = values.groupSync;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||||
form.resetDirty();
|
form.resetDirty();
|
||||||
@@ -92,7 +86,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("Display name")}
|
label="Display name"
|
||||||
placeholder="e.g Azure Entra"
|
placeholder="e.g Azure Entra"
|
||||||
data-autofocus
|
data-autofocus
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
@@ -129,15 +123,6 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
{...form.getInputProps("samlCertificate")}
|
{...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">
|
<Group justify="space-between">
|
||||||
<div>{t("Allow signup")}</div>
|
<div>{t("Allow signup")}</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ export enum SSO_PROVIDER {
|
|||||||
OIDC = 'oidc',
|
OIDC = 'oidc',
|
||||||
SAML = 'saml',
|
SAML = 'saml',
|
||||||
GOOGLE = 'google',
|
GOOGLE = 'google',
|
||||||
LDAP = 'ldap',
|
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,17 +9,8 @@ export interface IAuthProvider {
|
|||||||
oidcIssuer: string;
|
oidcIssuer: string;
|
||||||
oidcClientId: string;
|
oidcClientId: string;
|
||||||
oidcClientSecret: string;
|
oidcClientSecret: string;
|
||||||
ldapUrl: string;
|
|
||||||
ldapBindDn: string;
|
|
||||||
ldapBindPassword: string;
|
|
||||||
ldapBaseDn: string;
|
|
||||||
ldapUserSearchFilter: string;
|
|
||||||
ldapUserAttributes: any;
|
|
||||||
ldapTlsEnabled: boolean;
|
|
||||||
ldapTlsCaCert: string;
|
|
||||||
allowSignup: boolean;
|
allowSignup: boolean;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
groupSync: boolean;
|
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
AvatarIconType,
|
|
||||||
IAttachment,
|
|
||||||
} from "@/features/attachments/types/attachment.types.ts";
|
|
||||||
|
|
||||||
export async function uploadIcon(
|
|
||||||
file: File,
|
|
||||||
type: AvatarIconType,
|
|
||||||
spaceId?: string,
|
|
||||||
): Promise<IAttachment> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("type", type);
|
|
||||||
if (spaceId) {
|
|
||||||
formData.append("spaceId", spaceId);
|
|
||||||
}
|
|
||||||
formData.append("image", file);
|
|
||||||
|
|
||||||
return await api.post("/attachments/upload-image", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
|
|
||||||
return uploadIcon(file, AvatarIconType.AVATAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadSpaceIcon(
|
|
||||||
file: File,
|
|
||||||
spaceId: string,
|
|
||||||
): Promise<IAttachment> {
|
|
||||||
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
|
|
||||||
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeIcon(
|
|
||||||
type: AvatarIconType,
|
|
||||||
spaceId?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload: { spaceId?: string; type: string } = { type };
|
|
||||||
|
|
||||||
if (spaceId) {
|
|
||||||
payload.spaceId = spaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.post("/attachments/remove-icon", payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeAvatar(): Promise<void> {
|
|
||||||
await removeIcon(AvatarIconType.AVATAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeSpaceIcon(spaceId: string): Promise<void> {
|
|
||||||
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeWorkspaceIcon(): Promise<void> {
|
|
||||||
await removeIcon(AvatarIconType.WORKSPACE_ICON);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export {
|
|
||||||
uploadIcon,
|
|
||||||
uploadUserAvatar,
|
|
||||||
uploadSpaceIcon,
|
|
||||||
uploadWorkspaceIcon,
|
|
||||||
removeAvatar,
|
|
||||||
removeSpaceIcon,
|
|
||||||
removeWorkspaceIcon,
|
|
||||||
} from "./attachment-service.ts";
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export interface IAttachment {
|
|
||||||
id: string;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
fileSize: number;
|
|
||||||
fileExt: string;
|
|
||||||
mimeType: string;
|
|
||||||
type: string;
|
|
||||||
creatorId: string;
|
|
||||||
pageId: string | null;
|
|
||||||
spaceId: string | null;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
deletedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AvatarIconType {
|
|
||||||
AVATAR = "avatar",
|
|
||||||
SPACE_ICON = "space-icon",
|
|
||||||
WORKSPACE_ICON = "workspace-icon",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AttachmentType {
|
|
||||||
AVATAR = "avatar",
|
|
||||||
WORKSPACE_ICON = "workspace-icon",
|
|
||||||
SPACE_ICON = "space-icon",
|
|
||||||
FILE = "file",
|
|
||||||
}
|
|
||||||
@@ -9,21 +9,18 @@ import {
|
|||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
IconMoodSmile,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType } from "@docmost/editor-ext";
|
import { CalloutType } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
|
||||||
|
|
||||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -59,36 +56,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
[editor],
|
[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 (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -163,18 +130,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
<IconCircleXFilled size={18} />
|
<IconCircleXFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<EmojiPicker
|
|
||||||
onEmojiSelect={setCalloutIcon}
|
|
||||||
removeEmojiAction={removeCalloutIcon}
|
|
||||||
readOnly={false}
|
|
||||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
|
||||||
actionIconProps={{
|
|
||||||
size: "lg",
|
|
||||||
variant: "default",
|
|
||||||
c: undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
|
|||||||
|
|
||||||
export default function CalloutView(props: NodeViewProps) {
|
export default function CalloutView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { type, icon } = node.attrs;
|
const { type } = node.attrs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
@@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
title=""
|
title=""
|
||||||
color={getCalloutColor(type)}
|
color={getCalloutColor(type)}
|
||||||
icon={getCalloutIcon(type, icon)}
|
icon={getCalloutIcon(type)}
|
||||||
p="xs"
|
p="xs"
|
||||||
classNames={{
|
classNames={{
|
||||||
message: classes.message,
|
message: classes.message,
|
||||||
@@ -32,11 +32,7 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
function getCalloutIcon(type: CalloutType) {
|
||||||
if (customIcon && customIcon.trim() !== "") {
|
|
||||||
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return <IconInfoCircleFilled />;
|
return <IconInfoCircleFilled />;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import mermaid from "mermaid";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import classes from "./code-block.module.css";
|
import classes from "./code-block.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useComputedColorScheme } from "@mantine/core";
|
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
});
|
||||||
|
|
||||||
interface MermaidViewProps {
|
interface MermaidViewProps {
|
||||||
props: NodeViewProps;
|
props: NodeViewProps;
|
||||||
@@ -12,22 +16,12 @@ interface MermaidViewProps {
|
|||||||
|
|
||||||
export default function MermaidView({ props }: MermaidViewProps) {
|
export default function MermaidView({ props }: MermaidViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const computedColorScheme = useComputedColorScheme();
|
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const [preview, setPreview] = useState<string>("");
|
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(() => {
|
useEffect(() => {
|
||||||
const id = `mermaid-${uuidv4()}`;
|
const id = `mermaid-${uuidv4()}`;
|
||||||
|
|
||||||
if (node.textContent.length > 0) {
|
if (node.textContent.length > 0) {
|
||||||
mermaid
|
mermaid
|
||||||
.render(id, node.textContent)
|
.render(id, node.textContent)
|
||||||
@@ -46,7 +40,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [node.textContent, computedColorScheme]);
|
}, [node.textContent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima
|
|||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
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";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
@@ -33,9 +34,7 @@ export const handlePaste = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = match[6]; // Extract anchor from the regex match
|
createMentionAction(url, view, pos, creatorId);
|
||||||
const urlWithoutAnchor = anchor ? url.substring(0, url.indexOf("#")) : url;
|
|
||||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchor);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
EventExit,
|
EventExit,
|
||||||
EventSave,
|
EventSave,
|
||||||
} from "react-drawio";
|
} from "react-drawio";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/lib/types";
|
||||||
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
@@ -145,7 +145,6 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
color="gray"
|
color="gray"
|
||||||
mx="xs"
|
mx="xs"
|
||||||
className="print-hide"
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/lib/types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
@@ -183,7 +183,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
color="gray"
|
color="gray"
|
||||||
mx="xs"
|
mx="xs"
|
||||||
className="print-hide"
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
.anchorScrollMargin {
|
|
||||||
scroll-margin-top: 95px;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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,7 +9,6 @@ export type LinkFn = (
|
|||||||
view: EditorView,
|
view: EditorView,
|
||||||
pos: number,
|
pos: number,
|
||||||
creatorId: string,
|
creatorId: string,
|
||||||
anchor?: string,
|
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export interface InternalLinkOptions {
|
export interface InternalLinkOptions {
|
||||||
@@ -19,7 +18,7 @@ export interface InternalLinkOptions {
|
|||||||
|
|
||||||
export const handleInternalLink =
|
export const handleInternalLink =
|
||||||
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
|
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
|
||||||
async (url: string, view, pos, creatorId, anchor) => {
|
async (url: string, view, pos, creatorId) => {
|
||||||
const validated = validateFn(url, view);
|
const validated = validateFn(url, view);
|
||||||
if (!validated) return;
|
if (!validated) return;
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ export const handleInternalLink =
|
|||||||
entityId: page.id,
|
entityId: page.id,
|
||||||
slugId: page.slugId,
|
slugId: page.slugId,
|
||||||
creatorId: creatorId,
|
creatorId: creatorId,
|
||||||
anchor: anchor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
|
|||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
export default function MentionView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { label, entityType, entityId, slugId, anchor } = node.attrs;
|
const { label, entityType, entityId, slugId } = node.attrs;
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const {
|
const {
|
||||||
@@ -27,7 +27,6 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
shareId,
|
shareId,
|
||||||
pageSlugId: slugId,
|
pageSlugId: slugId,
|
||||||
pageTitle: label,
|
pageTitle: label,
|
||||||
anchor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +42,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
to={
|
to={
|
||||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor)
|
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
||||||
}
|
}
|
||||||
underline="never"
|
underline="never"
|
||||||
className={classes.pageMentionLink}
|
className={classes.pageMentionLink}
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconCalendar,
|
IconCalendar, IconAppWindow,
|
||||||
IconAppWindow,
|
} from '@tabler/icons-react';
|
||||||
IconSitemap,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@@ -359,15 +357,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.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",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.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 { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
@@ -38,9 +38,6 @@ import {
|
|||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
Subpages,
|
|
||||||
TableDndExtension,
|
|
||||||
HeadingAnchors,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -60,7 +57,6 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
|||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-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 plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
import abap from "highlightjs-sap-abap";
|
import abap from "highlightjs-sap-abap";
|
||||||
@@ -79,8 +75,6 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
|
|||||||
import EmojiCommand from "./emoji-command";
|
import EmojiCommand from "./emoji-command";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import { countWords } from "alfaaz";
|
import { countWords } from "alfaaz";
|
||||||
import UniqueID from "@tiptap/extension-unique-id";
|
|
||||||
import { generateEditorNodeId } from "../utils/nanoid";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@@ -97,7 +91,6 @@ lowlight.register("scala", scala);
|
|||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
history: false,
|
history: false,
|
||||||
heading: false,
|
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
width: 3,
|
width: 3,
|
||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
@@ -109,7 +102,6 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
HeadingAnchors,
|
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ node }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
@@ -170,13 +162,12 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
CustomTable.configure({
|
CustomTable.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: true,
|
lastColumnResizable: false,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
}),
|
}),
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableDndExtension,
|
|
||||||
MathInline.configure({
|
MathInline.configure({
|
||||||
view: MathInlineView,
|
view: MathInlineView,
|
||||||
}),
|
}),
|
||||||
@@ -221,9 +212,6 @@ export const mainExtensions = [
|
|||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
}),
|
}),
|
||||||
Subpages.configure({
|
|
||||||
view: SubpagesView,
|
|
||||||
}),
|
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
@@ -246,12 +234,6 @@ export const mainExtensions = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
UniqueID.configure({
|
|
||||||
types: ["heading"],
|
|
||||||
attributeName: "nodeId",
|
|
||||||
generateID: () => generateEditorNodeId(),
|
|
||||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
|
||||||
}),
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
|||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
@@ -50,8 +49,6 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
|
||||||
import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -88,7 +85,6 @@ export default function PageEditor({
|
|||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
// useAnchorScroll();
|
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
|
|
||||||
@@ -225,10 +221,6 @@ export default function PageEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
|
||||||
searchSpotlight.open();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
const slashCommand = document.querySelector("#slash-command");
|
||||||
if (slashCommand) {
|
if (slashCommand) {
|
||||||
@@ -383,7 +375,7 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-container" style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
@@ -399,7 +391,6 @@ export default function PageEditor({
|
|||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
<SubpagesMenu editor={editor} />
|
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ import { Document } from "@tiptap/extension-document";
|
|||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
import { Text } from "@tiptap/extension-text";
|
import { Text } from "@tiptap/extension-text";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai/index";
|
||||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import {
|
||||||
|
pageEditorAtom,
|
||||||
|
readOnlyEditorAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: any;
|
content: any;
|
||||||
pageId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReadonlyPageEditor({
|
export default function ReadonlyPageEditor({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
pageId,
|
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||||
|
|
||||||
@@ -54,9 +56,6 @@ export default function ReadonlyPageEditor({
|
|||||||
content={content}
|
content={content}
|
||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
if (pageId) {
|
|
||||||
editor.storage.pageId = pageId;
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setReadOnlyEditor(editor);
|
setReadOnlyEditor(editor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.65em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.65em;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
@@ -57,7 +57,6 @@
|
|||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
@@ -94,12 +93,8 @@
|
|||||||
|
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
@mixin light {
|
border-top: 2px solid #ced4da;
|
||||||
border-top: 1px solid var(--mantine-color-gray-4);
|
margin: 2rem 0;
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
border-top: 1px solid var(--mantine-color-dark-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -141,7 +136,7 @@
|
|||||||
|
|
||||||
.selection,
|
.selection,
|
||||||
*::selection {
|
*::selection {
|
||||||
background-color: Highlight;
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-gray-7));
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-mark {
|
.comment-mark {
|
||||||
|
|||||||
@@ -45,10 +45,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-direction='horizontal'] {
|
|
||||||
rotate: 90deg;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .drag-handle {
|
.dark .drag-handle {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
.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,4 +12,3 @@
|
|||||||
@import "./find.css";
|
@import "./find.css";
|
||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
@import "./heading-anchors.css";
|
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
@media print {
|
@media print {
|
||||||
.mantine-AppShell-header,
|
.mantine-AppShell-header,
|
||||||
.mantine-AppShell-navbar,
|
.mantine-AppShell-navbar,
|
||||||
.mantine-AppShell-aside,
|
.mantine-AppShell-aside{
|
||||||
.print-hide,
|
display: none !important;
|
||||||
.drag-handle {
|
}
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mantine-AppShell-main {
|
.mantine-AppShell-main {
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
min-height: auto !important;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-selectednode {
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableWrapper {
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.ProseMirror {
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -128,13 +118,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
|
|
||||||
.prosemirror-dropcursor-block {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prosemirror-dropcursor-inline {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ ul[data-type="taskList"] {
|
|||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
> label {
|
> label {
|
||||||
padding-top: 0.2rem;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { UpdateEvent } from "@/features/websocket/types";
|
|||||||
import localEmitter from "@/lib/local-emitter.ts";
|
import localEmitter from "@/lib/local-emitter.ts";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -87,20 +86,6 @@ export function TitleEditor({
|
|||||||
content: title,
|
content: title,
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -208,7 +193,7 @@ export function TitleEditor({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
// First handle the search hotkey
|
// First handle the search hotkey
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||||
|
|
||||||
// Then handle other key events
|
// Then handle other key events
|
||||||
handleTitleKeyDown(event);
|
handleTitleKeyDown(event);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { customAlphabet } from "nanoid";
|
|
||||||
|
|
||||||
const slugIdAlphabet =
|
|
||||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
||||||
export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12);
|
|
||||||
@@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
||||||
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
||||||
@@ -84,12 +84,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
const markdownFileRef = useRef<() => void>(null);
|
|
||||||
const htmlFileRef = useRef<() => void>(null);
|
|
||||||
const notionFileRef = useRef<() => void>(null);
|
|
||||||
const confluenceFileRef = useRef<() => void>(null);
|
|
||||||
const zipFileRef = useRef<() => void>(null);
|
|
||||||
|
|
||||||
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||||
|
|
||||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||||
@@ -122,15 +116,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setFileTaskId(importTask.id);
|
setFileTaskId(importTask.id);
|
||||||
|
|
||||||
// Reset file input after successful upload
|
|
||||||
if (source === "notion" && notionFileRef.current) {
|
|
||||||
notionFileRef.current();
|
|
||||||
} else if (source === "confluence" && confluenceFileRef.current) {
|
|
||||||
confluenceFileRef.current();
|
|
||||||
} else if (source === "generic" && zipFileRef.current) {
|
|
||||||
zipFileRef.current();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("Failed to upload import file", err);
|
console.log("Failed to upload import file", err);
|
||||||
notifications.update({
|
notifications.update({
|
||||||
@@ -258,10 +243,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
setTreeData(fullTree);
|
setTreeData(fullTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset file inputs after successful upload
|
|
||||||
if (markdownFileRef.current) markdownFileRef.current();
|
|
||||||
if (htmlFileRef.current) htmlFileRef.current();
|
|
||||||
|
|
||||||
const pageCountText =
|
const pageCountText =
|
||||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||||
|
|
||||||
@@ -291,7 +272,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
|
<FileButton onChange={handleFileUpload} accept=".md" multiple>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@@ -304,7 +285,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
|
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@@ -320,7 +301,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "notion")}
|
onChange={(file) => handleZipUpload(file, "notion")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
resetRef={notionFileRef}
|
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -336,7 +316,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "confluence")}
|
onChange={(file) => handleZipUpload(file, "confluence")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
resetRef={confluenceFileRef}
|
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -373,7 +352,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "generic")}
|
onChange={(file) => handleZipUpload(file, "generic")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
resetRef={zipFileRef}
|
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
|
|||||||
@@ -15,29 +15,22 @@ export const buildPageUrl = (
|
|||||||
spaceName: string,
|
spaceName: string,
|
||||||
pageSlugId: string,
|
pageSlugId: string,
|
||||||
pageTitle?: string,
|
pageTitle?: string,
|
||||||
anchor?: string,
|
|
||||||
): string => {
|
): string => {
|
||||||
let url: string;
|
|
||||||
if (spaceName === undefined) {
|
if (spaceName === undefined) {
|
||||||
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
} else {
|
|
||||||
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
|
||||||
}
|
}
|
||||||
return anchor ? `${url}#${anchor}` : url;
|
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildSharedPageUrl = (opts: {
|
export const buildSharedPageUrl = (opts: {
|
||||||
shareId: string;
|
shareId: string;
|
||||||
pageSlugId: string;
|
pageSlugId: string;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
anchor?: string;
|
|
||||||
}): string => {
|
}): string => {
|
||||||
const { shareId, pageSlugId, pageTitle, anchor } = opts;
|
const { shareId, pageSlugId, pageTitle } = opts;
|
||||||
let url: string;
|
|
||||||
if (!shareId) {
|
if (!shareId) {
|
||||||
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
} else {
|
|
||||||
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
|
||||||
}
|
}
|
||||||
return anchor ? `${url}#${anchor}` : url;
|
|
||||||
|
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ export function useGetSidebarPagesQuery(
|
|||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getPreviousPageParam: (firstPage) =>
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import {
|
|||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from '@/features/page/types/page.types';
|
} from '@/features/page/types/page.types';
|
||||||
import { QueryParams } from "@/lib/types";
|
import { QueryParams } from "@/lib/types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { InfiniteData } from "@tanstack/react-query";
|
import { InfiniteData } from "@tanstack/react-query";
|
||||||
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
||||||
import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
|
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId?: string;
|
spaceId: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
page?: number; // pagination
|
page?: number; // pagination
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
.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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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,17 +1,15 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
searchAttachments,
|
|
||||||
searchPage,
|
searchPage,
|
||||||
searchShare,
|
searchShare,
|
||||||
searchSuggestions,
|
searchSuggestions,
|
||||||
} from '@/features/search/services/search-service';
|
} from "@/features/search/services/search-service";
|
||||||
import {
|
import {
|
||||||
IAttachmentSearch,
|
|
||||||
IPageSearch,
|
IPageSearch,
|
||||||
IPageSearchParams,
|
IPageSearchParams,
|
||||||
ISuggestionResult,
|
ISuggestionResult,
|
||||||
SearchSuggestionParams,
|
SearchSuggestionParams,
|
||||||
} from '@/features/search/types/search.types';
|
} from "@/features/search/types/search.types";
|
||||||
|
|
||||||
export function usePageSearchQuery(
|
export function usePageSearchQuery(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
@@ -43,13 +41,3 @@ export function useShareSearchQuery(
|
|||||||
enabled: !!params.query,
|
enabled: !!params.query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAttachmentSearchQuery(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
): UseQueryResult<IAttachmentSearch[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["attachment-search", params],
|
|
||||||
queryFn: () => searchAttachments(params),
|
|
||||||
enabled: !!params.query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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,11 +1,10 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
IAttachmentSearch,
|
|
||||||
IPageSearch,
|
IPageSearch,
|
||||||
IPageSearchParams,
|
IPageSearchParams,
|
||||||
ISuggestionResult,
|
ISuggestionResult,
|
||||||
SearchSuggestionParams,
|
SearchSuggestionParams,
|
||||||
} from '@/features/search/types/search.types';
|
} from "@/features/search/types/search.types";
|
||||||
|
|
||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
@@ -27,10 +26,3 @@ export async function searchShare(
|
|||||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAttachments(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
): Promise<IAttachmentSearch[]> {
|
|
||||||
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-7
@@ -9,7 +9,6 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|||||||
import { getPageIcon } from "@/lib";
|
import { getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
|
|
||||||
interface ShareSearchSpotlightProps {
|
interface ShareSearchSpotlightProps {
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
@@ -48,12 +47,7 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
|||||||
<Text
|
<Text
|
||||||
opacity={0.6}
|
opacity={0.6}
|
||||||
size="xs"
|
size="xs"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
||||||
__html: DOMPurify.sanitize(page.highlight, {
|
|
||||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
|
||||||
ALLOWED_ATTR: []
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,25 +37,3 @@ export interface IPageSearchParams {
|
|||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
|
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useCreateShareMutation,
|
useCreateShareMutation,
|
||||||
@@ -18,27 +18,23 @@ import {
|
|||||||
useShareForPageQuery,
|
useShareForPageQuery,
|
||||||
useUpdateShareMutation,
|
useUpdateShareMutation,
|
||||||
} from "@/features/share/queries/share-query.ts";
|
} from "@/features/share/queries/share-query.ts";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
import { extractPageSlugId, getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
import CopyTextButton from "@/components/common/copy.tsx";
|
||||||
import { getAppUrl, isCloud } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import classes from "@/features/share/components/share.module.css";
|
import classes from "@/features/share/components/share.module.css";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
export default function ShareModal({ readOnly }: ShareModalProps) {
|
export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const pageId = extractPageSlugId(pageSlug);
|
const pageId = extractPageSlugId(pageSlug);
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
const { data: share } = useShareForPageQuery(pageId);
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { isTrial } = useTrial();
|
|
||||||
const createShareMutation = useCreateShareMutation();
|
const createShareMutation = useCreateShareMutation();
|
||||||
const updateShareMutation = useUpdateShareMutation();
|
const updateShareMutation = useUpdateShareMutation();
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
const deleteShareMutation = useDeleteShareMutation();
|
||||||
@@ -65,7 +61,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
createShareMutation.mutateAsync({
|
createShareMutation.mutateAsync({
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
includeSubPages: true,
|
includeSubPages: true,
|
||||||
searchIndexing: false,
|
searchIndexing: true,
|
||||||
});
|
});
|
||||||
setIsPagePublic(value);
|
setIsPagePublic(value);
|
||||||
} else {
|
} else {
|
||||||
@@ -96,29 +92,26 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareLink = useMemo(
|
const shareLink = useMemo(() => (
|
||||||
() => (
|
<Group my="sm" gap={4} wrap="nowrap">
|
||||||
<Group my="sm" gap={4} wrap="nowrap">
|
<TextInput
|
||||||
<TextInput
|
variant="filled"
|
||||||
variant="filled"
|
value={publicLink}
|
||||||
value={publicLink}
|
readOnly
|
||||||
readOnly
|
rightSection={<CopyTextButton text={publicLink} />}
|
||||||
rightSection={<CopyTextButton text={publicLink} />}
|
style={{ width: "100%" }}
|
||||||
style={{ width: "100%" }}
|
/>
|
||||||
/>
|
<ActionIcon
|
||||||
<ActionIcon
|
component="a"
|
||||||
component="a"
|
variant="default"
|
||||||
variant="default"
|
target="_blank"
|
||||||
target="_blank"
|
href={publicLink}
|
||||||
href={publicLink}
|
size="sm"
|
||||||
size="sm"
|
>
|
||||||
>
|
<IconExternalLink size={16} />
|
||||||
<IconExternalLink size={16} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
</Group>
|
||||||
</Group>
|
), [publicLink]);
|
||||||
),
|
|
||||||
[publicLink],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
<Popover width={350} position="bottom" withArrow shadow="md">
|
||||||
@@ -142,28 +135,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown style={{ userSelect: "none" }}>
|
<Popover.Dropdown style={{ userSelect: "none" }}>
|
||||||
{isCloud() && isTrial ? (
|
{isDescendantShared ? (
|
||||||
<>
|
|
||||||
<Group justify="center" mb="sm">
|
|
||||||
<IconLock size={20} stroke={1.5} />
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" ta="center" fw={500} mb="xs">
|
|
||||||
{t("Upgrade to share pages")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" ta="center" mb="sm">
|
|
||||||
{t(
|
|
||||||
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
onClick={() => navigate("/settings/billing")}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t("Upgrade Plan")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : isDescendantShared ? (
|
|
||||||
<>
|
<>
|
||||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
||||||
<Anchor
|
<Anchor
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user