Merge branch 'main' into permissions-2

This commit is contained in:
Philipinho
2025-12-18 18:30:48 +00:00
294 changed files with 13523 additions and 2528 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder FROM base AS builder
@@ -13,7 +13,9 @@ RUN pnpm build
FROM base AS installer FROM base AS installer
RUN apk add --no-cache curl bash RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+11 -3
View File
@@ -2,10 +2,18 @@
<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="/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
<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>
+8 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.22.2", "version": "0.24.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3", "@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^8.1.3",
@@ -26,7 +27,7 @@
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3", "@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.9.0", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -40,7 +41,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.6.0", "mermaid": "^11.11.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",
@@ -56,7 +57,7 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.56" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
@@ -64,10 +65,10 @@
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.10.0", "@types/node": "22.19.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
@@ -80,6 +81,6 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.17.0",
"vite": "^6.3.5" "vite": "^7.2.4"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

@@ -42,7 +42,7 @@
"Delete group": "Gruppe löschen", "Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Einzelheiten", "Details": "Details",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.", "e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler", "e.g Developers": "z.B. Entwickler",
@@ -53,6 +53,7 @@
"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",
@@ -233,9 +234,7 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -495,5 +494,78 @@
"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",
"Icon": "Icon",
"Upload image": "Bild hochladen",
"Remove image": "Bild entfernen",
"Failed to remove image": "Fehler beim Entfernen des Bildes",
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API-Schlüssel",
"API key created successfully": "API-Schlüssel erfolgreich erstellt",
"API keys": "API-Schlüssel",
"API management": "API-Verwaltung",
"Are you sure you want to revoke this API key": "Sind Sie sicher, dass Sie diesen API-Schlüssel widerrufen möchten?",
"Create API Key": "API-Schlüssel erstellen",
"Custom expiration date": "Benutzerdefiniertes Ablaufdatum",
"Enter a descriptive token name": "Geben Sie einen beschreibenden Token-Namen ein",
"Expiration": "Ablauf",
"Expired": "Abgelaufen",
"Expires": "Läuft ab",
"I've saved my API key": "Ich habe meinen API-Schlüssel gespeichert",
"Last use": "Zuletzt verwendet",
"No API keys found": "Keine API-Schlüssel gefunden",
"No expiration": "Kein Ablauf",
"Revoke API key": "API-Schlüssel widerrufen",
"Revoked successfully": "Erfolgreich widerrufen",
"Select expiration date": "Ablaufdatum wählen",
"This action cannot be undone. Any applications using this API key will stop working.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Anwendungen, die diesen API-Schlüssel verwenden, werden nicht mehr funktionieren.",
"Update API key": "API-Schlüssel aktualisieren",
"Manage API keys for all users in the workspace": "Verwalten Sie API-Schlüssel für alle Benutzer im Arbeitsbereich",
"AI settings": "KI-Einstellungen",
"AI search": "KI-Suche",
"AI Answer": "KI-Antwort",
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Sources": "Quellen",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe",
"Remove color": "Farbe entfernen"
} }
@@ -53,6 +53,7 @@
"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",
@@ -495,5 +496,78 @@
"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",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"AI settings": "AI settings",
"AI search": "AI search",
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Sources": "Sources",
"Ask AI not available for attachments": "Ask AI not available for attachments",
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight color",
"Remove color": "Remove color"
} }
@@ -53,6 +53,7 @@
"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",
@@ -495,5 +496,78 @@
"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}}",
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"AI settings": "Configuración de IA",
"AI search": "Búsqueda de IA",
"AI Answer": "Respuesta de IA",
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Sources": "Fuentes",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"Remove color": "Eliminar color"
} }
@@ -53,6 +53,7 @@
"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",
@@ -495,5 +496,78 @@
"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}}",
"Icon": "Icône",
"Upload image": "Téléverser une image",
"Remove image": "Supprimer l'image",
"Failed to remove image": "Échec de la suppression de l'image",
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"AI settings": "Paramètres de l'IA",
"AI search": "Recherche IA",
"AI Answer": "Réponse IA",
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Sources": "Sources",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur"
} }
@@ -53,6 +53,7 @@
"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",
@@ -495,5 +496,78 @@
"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}}",
"Icon": "Icona",
"Upload image": "Carica immagine",
"Remove image": "Rimuovi immagine",
"Failed to remove image": "Rimozione immagine fallita",
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"AI settings": "Impostazioni AI",
"AI search": "Ricerca AI",
"AI Answer": "Risposta AI",
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Sources": "Fonti",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato",
"Remove color": "Rimuovi colore"
} }
@@ -53,6 +53,7 @@
"e.g Space for product team": "例: 製品チームのスペース", "e.g Space for product team": "例: 製品チームのスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース", "e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集", "Edit": "編集",
"Read": "読む",
"Edit group": "グループを編集", "Edit group": "グループを編集",
"Email": "メールアドレス", "Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください", "Enter a strong password": "強力なパスワードを入力してください",
@@ -495,5 +496,78 @@
"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}}の構成",
"Icon": "アイコン",
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています。",
"Image removed successfully": "画像が正常に削除されました",
"API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します。",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
"AI search": "AI検索",
"AI Answer": "AI回答",
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用して、ワークスペースコンテンツ全体にわたって意味検索機能を提供します。",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight color": "ハイライト色",
"Remove color": "色を削除"
} }
@@ -53,6 +53,7 @@
"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": "강력한 비밀번호를 입력하세요",
@@ -495,5 +496,78 @@
"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}} 구성",
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"AI settings": "AI 설정",
"AI search": "AI 검색",
"AI Answer": "AI 답변",
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Sources": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight color": "강조 색",
"Remove color": "색 제거"
} }
@@ -34,7 +34,7 @@
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken", "Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken", "Create space": "Ruimte aanmaken",
"Create workspace": "Wwerkruimte aanmaken", "Create workspace": "Werkruimte aanmaken",
"Current password": "Huidig wachtwoord", "Current password": "Huidig wachtwoord",
"Dark": "Donker", "Dark": "Donker",
"Date": "Datum", "Date": "Datum",
@@ -53,6 +53,7 @@
"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",
@@ -90,7 +91,7 @@
"Invite by email": "Uitnodigen via e-mail", "Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen", "Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen", "Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.", "Invited members who are yet to accept their invitation will appear here.": "Uitgenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft", "Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte", "Join the workspace": "Word lid van de werkruimte",
"Language": "Taal", "Language": "Taal",
@@ -495,5 +496,78 @@
"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",
"Icon": "Icoon",
"Upload image": "Afbeelding uploaden",
"Remove image": "Afbeelding verwijderen",
"Failed to remove image": "Afbeelding verwijderen mislukt",
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"AI settings": "AI-instellingen",
"AI search": "AI-zoekopdracht",
"AI Answer": "AI Antwoord",
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Sources": "Bronnen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen"
} }
@@ -53,6 +53,7 @@
"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",
@@ -495,5 +496,78 @@
"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}}",
"Icon": "Ícone",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Sources": "Fontes",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"Remove color": "Remover cor"
} }
@@ -53,6 +53,7 @@
"e.g Space for product team": "например, Пространство для продуктовой команды", "e.g Space for product team": "например, Пространство для продуктовой команды",
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж", "e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
"Edit": "Редактировать", "Edit": "Редактировать",
"Read": "Читать",
"Edit group": "Редактировать группу", "Edit group": "Редактировать группу",
"Email": "Электронная почта", "Email": "Электронная почта",
"Enter a strong password": "Введите надёжный пароль", "Enter a strong password": "Введите надёжный пароль",
@@ -495,5 +496,78 @@
"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}}",
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"AI settings": "Настройки ИИ",
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Sources": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет"
} }
@@ -53,6 +53,7 @@
"e.g Space for product team": "наприклад, Простір для продуктової команди", "e.g Space for product team": "наприклад, Простір для продуктової команди",
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів", "e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
"Edit": "Редагувати", "Edit": "Редагувати",
"Read": "Читати",
"Edit group": "Редагувати групу", "Edit group": "Редагувати групу",
"Email": "Електронна пошта", "Email": "Електронна пошта",
"Enter a strong password": "Введіть надійний пароль", "Enter a strong password": "Введіть надійний пароль",
@@ -495,5 +496,78 @@
"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}}",
"Icon": "Іконка",
"Upload image": "Завантажити зображення",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"AI settings": "Налаштування ШІ",
"AI search": "Пошук з ШІ",
"AI Answer": "Відповідь ШІ",
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Sources": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір"
} }
@@ -53,6 +53,7 @@
"e.g Space for product team": "例如:产品团队的空间", "e.g Space for product team": "例如:产品团队的空间",
"e.g Space for sales team to collaborate": "例如:销售团队协作的空间", "e.g Space for sales team to collaborate": "例如:销售团队协作的空间",
"Edit": "编辑", "Edit": "编辑",
"Read": "阅读",
"Edit group": "编辑群组", "Edit group": "编辑群组",
"Email": "电子邮箱", "Email": "电子邮箱",
"Enter a strong password": "输入一个强密码", "Enter a strong password": "输入一个强密码",
@@ -495,5 +496,78 @@
"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}} 配置",
"Icon": "图标",
"Upload image": "上传图片",
"Remove image": "删除图片",
"Failed to remove image": "无法删除图片",
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"AI settings": "AI设置",
"AI search": "AI搜索",
"AI Answer": "AI回答",
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Sources": "来源",
"Ask AI not available for attachments": "附件不支持询问AI",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight color": "突出显示颜色",
"Remove color": "移除颜色"
} }
+30
View File
@@ -0,0 +1,30 @@
{
"name": "Docmost",
"short_name": "Docmost",
"start_url": "/",
"display": "standalone",
"background_color": "#222",
"theme_color": "#222",
"icons": [
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+6
View File
@@ -35,6 +35,9 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -96,13 +99,16 @@ export default function App() {
path={"account/preferences"} path={"account/preferences"}
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -0,0 +1,165 @@
import React, { useRef } from "react";
import { Menu, Box, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconTrash, IconUpload } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { notifications } from "@mantine/notifications";
interface AvatarUploaderProps {
currentImageUrl?: string | null;
fallbackName?: string;
radius?: string | number;
size?: string | number;
variant?: string;
type: AvatarIconType;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
isLoading?: boolean;
disabled?: boolean;
}
export default function AvatarUploader({
currentImageUrl,
fallbackName,
radius,
variant,
size,
type,
onUpload,
onRemove,
isLoading = false,
disabled = false,
}: AvatarUploaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file || disabled) {
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
notifications.show({
message: t("Image exceeds 10MB limit."),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
await onUpload(file);
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to upload image"),
color: "red",
});
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
console.error("File input ref is null!");
}
};
const handleRemove = async () => {
if (disabled) return;
try {
await onRemove();
notifications.show({
message: t("Image removed successfully"),
});
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to remove image"),
color: "red",
});
}
};
return (
<Box>
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
style={{ display: "none" }}
/>
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
<Menu.Target>
<Box style={{ position: "relative", display: "inline-block" }}>
<CustomAvatar
component="button"
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
radius={radius}
variant={variant}
type={type}
/>
{isLoading && (
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
}}
>
<Loader size="sm" />
</Box>
)}
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUpload size={16} />}
disabled={isLoading || disabled}
onClick={handleUploadClick}
>
{t("Upload image")}
</Menu.Item>
{currentImageUrl && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={handleRemove}
disabled={isLoading || disabled}
>
{t("Remove image")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}
@@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps { interface NoTableResultsProps {
colSpan: number; colSpan: number;
text?: string;
} }
export default function NoTableResults({ colSpan }: NoTableResultsProps) { export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={colSpan}> <Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center"> <Text fw={500} c="dimmed" ta="center">
{t("No results found...")} {text || t("No results found...")}
</Text> </Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -14,6 +14,14 @@ 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" }];
@@ -79,6 +87,15 @@ 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,16 +1,23 @@
import { UserProvider } from "@/features/user/user-provider.tsx"; import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom"; import { Outlet, useParams } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx"; import 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,
UnstyledButton,
Text, Text,
UnstyledButton,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -10,7 +10,6 @@ import {
IconBrush, IconBrush,
IconCheck, IconCheck,
IconChevronDown, IconChevronDown,
IconChevronRight,
IconDeviceDesktop, IconDeviceDesktop,
IconLogout, IconLogout,
IconMoon, IconMoon,
@@ -26,6 +25,7 @@ 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,6 +50,7 @@ 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"
> >
v{APP_VERSION} {appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
</Text> </Text>
</Indicator> </Indicator>
</Tooltip> </Tooltip>
@@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts"; import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params = { limit: 100, page: 1, query: "" } as QueryParams;
@@ -65,3 +66,17 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }), queryFn: () => getShares({ page: 1, limit: 100 }),
}); });
}; };
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
});
};
@@ -12,15 +12,18 @@ import {
IconLock, IconLock,
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
@@ -60,6 +63,14 @@ const groupedData: DataGroup[] = [
icon: IconBrush, icon: IconBrush,
path: "/settings/account/preferences", path: "/settings/account/preferences",
}, },
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
], ],
}, },
{ {
@@ -90,6 +101,22 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
isSelfhosted: true,
},
], ],
}, },
{ {
@@ -195,6 +222,12 @@ export default function SettingsSidebar() {
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
break; break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
default: default:
break; break;
} }
@@ -1,6 +1,7 @@
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;
@@ -11,13 +12,15 @@ 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, ...props }: CustomAvatarProps, ref) => { >(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl); const avatarLink = getAvatarUrl(avatarUrl, type);
return ( return (
<Avatar <Avatar
+12 -1
View File
@@ -15,6 +15,11 @@ 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({
@@ -22,6 +27,7 @@ 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);
@@ -64,7 +70,12 @@ function EmojiPicker({
closeOnEscape={true} closeOnEscape={true}
> >
<Popover.Target ref={setTarget}> <Popover.Target ref={setTarget}>
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}> <ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
onClick={handlers.toggle}
>
{icon} {icon}
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>
@@ -0,0 +1,47 @@
import { Box } from "@mantine/core";
import React from "react";
interface ResponsiveSettingsRowProps {
children: React.ReactNode;
}
export function ResponsiveSettingsRow({ children }: ResponsiveSettingsRowProps) {
return (
<Box
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
{children}
</Box>
);
}
interface ResponsiveSettingsContentProps {
children: React.ReactNode;
}
export function ResponsiveSettingsContent({ children }: ResponsiveSettingsContentProps) {
return (
<Box style={{ flex: "1 1 300px", minWidth: 0 }}>
{children}
</Box>
);
}
interface ResponsiveSettingsControlProps {
children: React.ReactNode;
}
export function ResponsiveSettingsControl({ children }: ResponsiveSettingsControlProps) {
return (
<Box style={{ flex: "0 0 auto" }}>
{children}
</Box>
);
}
@@ -0,0 +1,113 @@
import React, { useMemo } from "react";
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
import { IconSparkles, IconFileText } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { IAiSearchResponse } from "../services/ai-search-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
interface AiSearchResultProps {
result?: IAiSearchResponse;
isLoading?: boolean;
streamingAnswer?: string;
streamingSources?: any[];
}
export function AiSearchResult({
result,
isLoading,
streamingAnswer = "",
streamingSources = [],
}: AiSearchResultProps) {
const { t } = useTranslation();
// Use streaming data if available, otherwise fall back to result
const answer = streamingAnswer || result?.answer || "";
const sources =
streamingSources.length > 0 ? streamingSources : result?.sources || [];
// Deduplicate sources by pageId, keeping the one with highest similarity
const deduplicatedSources = useMemo(() => {
if (!sources || sources.length === 0) return [];
const pageMap = new Map();
sources.forEach((source) => {
const existing = pageMap.get(source.pageId);
if (!existing || source.similarity > existing.similarity) {
pageMap.set(source.pageId, source);
}
});
return Array.from(pageMap.values());
}, [sources]);
if (isLoading && !answer) {
return (
<Paper p="md" radius="md" withBorder>
<Group>
<Loader size="sm" />
<Text size="sm">{t("AI is thinking...")}</Text>
</Group>
</Paper>
);
}
if (!answer && !isLoading) {
return null;
}
return (
<Stack gap="md" p="md">
<Paper p="md" radius="md" withBorder>
<Group gap="xs" mb="sm">
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm">
{t("AI Answer")}
</Text>
{isLoading && <Loader size="xs" />}
</Group>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
}}
/>
</Paper>
{deduplicatedSources.length > 0 && (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sources")}
</Text>
{deduplicatedSources.map((source) => (
<Box
key={source.pageId}
component={Link}
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<Paper
p="xs"
radius="sm"
withBorder
style={{ cursor: "pointer" }}
>
<Group gap="xs">
<IconFileText size={16} />
<Text size="sm" truncate>
{source.title}
</Text>
</Group>
</Paper>
</Box>
))}
</Stack>
)}
</Stack>
);
}
@@ -0,0 +1,69 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
)}
</Text>
</div>
<AiSearchToggle />
</Group>
</>
);
}
interface AiSearchToggleProps {
size?: MantineSize;
label?: string;
}
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -0,0 +1,46 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
streamingAnswer: string;
streamingSources: any[];
clearStreaming: () => void;
}
export function useAiSearch(): UseAiSearchResult {
const [streamingAnswer, setStreamingAnswer] = useState("");
const [streamingSources, setStreamingSources] = useState<any[]>([]);
const clearStreaming = useCallback(() => {
setStreamingAnswer("");
setStreamingSources([]);
}, []);
const mutation = useMutation({
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
setStreamingAnswer("");
setStreamingSources([]);
const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
if (chunk.sources) {
setStreamingSources(chunk.sources);
}
});
},
});
return {
...mutation,
streamingAnswer,
streamingSources,
clearStreaming,
};
}
+61
View File
@@ -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,46 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<EnableAiSearch />
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {
useMutation,
UseMutationResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
generateAiContent,
generateAiContentStream,
} 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,83 @@
import api from "@/lib/api-client.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
export interface IAiSearchResponse {
answer: string;
sources?: Array<{
pageId: string;
title: string;
slugId: string;
spaceSlug: string;
similarity: number;
distance: number;
chunkIndex: number;
excerpt: string;
}>;
}
export async function askAi(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let answer = "";
let sources: any[] = [];
let buffer = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last incomplete line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
throw new Error(parsed.error);
}
if (parsed.content) {
answer += parsed.content;
onChunk?.({ content: parsed.content });
}
if (parsed.sources) {
sources = parsed.sources;
onChunk?.({ sources: parsed.sources });
}
} catch (e) {
if (e instanceof Error) {
throw e;
}
// Skip invalid JSON
}
}
}
}
}
return { answer, sources };
}
@@ -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;
}
+40
View File
@@ -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;
}
@@ -0,0 +1,72 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import CopyTextButton from "@/components/common/copy.tsx";
interface ApiKeyCreatedModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey;
}
export function ApiKeyCreatedModal({
opened,
onClose,
apiKey,
}: ApiKeyCreatedModalProps) {
const { t } = useTranslation();
if (!apiKey) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("API key")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{
flex: 1,
}}
value={apiKey.token}
readOnly
/>
<CopyTextButton text={apiKey.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
);
}
@@ -0,0 +1,143 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
isLoading?: boolean;
showUserColumn?: boolean;
onUpdate?: (apiKey: IApiKey) => void;
onRevoke?: (apiKey: IApiKey) => void;
}
export function ApiKeyTable({
apiKeys,
isLoading,
showUserColumn = false,
onUpdate,
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys && apiKeys.length > 0 ? (
apiKeys.map((apiKey: IApiKey, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Text fz="sm" fw={500}>
{apiKey.name}
</Text>
</Table.Td>
{showUserColumn && apiKey.creator && (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={apiKey.creator?.avatarUrl}
name={apiKey.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{apiKey.creator.name}
</Text>
</Group>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
isExpired(apiKey.expiresAt) ? (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Expired")}
</Text>
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.expiresAt)}
</Text>
)
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(apiKey)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(apiKey)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,153 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
import { IApiKey } from "@/ee/api-key";
const DateInput = lazy(() =>
import("@mantine/dates").then((module) => ({
default: module.DateInput,
})),
);
interface CreateApiKeyModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IApiKey) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateApiKeyModal({
opened,
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
},
});
const getExpirationDate = (): string | undefined => {
if (expirationOption === "never") {
return undefined;
}
if (expirationOption === "custom") {
return form.values.expiresAt;
}
const days = parseInt(expirationOption);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
};
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
return `${days} days (${formatted})`;
};
const expirationOptions = [
{ value: "30", label: getExpirationLabel(30) },
{ value: "60", label: getExpirationLabel(60) },
{ value: "90", label: getExpirationLabel(90) },
{ value: "365", label: getExpirationLabel(365) },
{ value: "custom", label: t("Custom") },
{ value: "never", label: t("No expiration") },
];
const handleSubmit = async (data: {
name?: string;
expiresAt?: string | Date;
}) => {
const groupData = {
name: data.name,
expiresAt: getExpirationDate(),
};
try {
const createdKey = await createApiKeyMutation.mutateAsync(groupData);
onSuccess(createdKey);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
setExpirationOption("30");
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Select
label={t("Expiration")}
data={expirationOptions}
value={expirationOption}
onChange={(value) => setExpirationOption(value || "30")}
leftSection={<IconCalendar size={16} />}
allowDeselect={false}
/>
{expirationOption === "custom" && (
<Suspense fallback={null}>
<DateInput
label={t("Custom expiration date")}
placeholder={t("Select expiration date")}
minDate={new Date()}
{...form.getInputProps("expiresAt")}
/>
</Suspense>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createApiKeyMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,62 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
interface RevokeApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function RevokeApiKeyModal({
opened,
onClose,
apiKey,
}: RevokeApiKeyModalProps) {
const { t } = useTranslation();
const revokeApiKeyMutation = useRevokeApiKeyMutation();
const handleRevoke = async () => {
if (!apiKey) return;
await revokeApiKeyMutation.mutateAsync({
apiKeyId: apiKey.id,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Any applications using this API key will stop working.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeApiKeyMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,80 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
import { useEffect } from "react";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function UpdateApiKeyModal({
opened,
onClose,
apiKey,
}: UpdateApiKeyModalProps) {
const { t } = useTranslation();
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
},
});
useEffect(() => {
if (opened && apiKey) {
form.setValues({ name: apiKey.name });
}
}, [opened, apiKey]);
const handleSubmit = async (data: { name?: string }) => {
const apiKeyData = {
apiKeyId: apiKey.id,
name: data.name,
};
await updateApiKeyMutation.mutateAsync(apiKeyData);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive token name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateApiKeyMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
+11
View File
@@ -0,0 +1,11 @@
export { ApiKeyTable } from "./components/api-key-table";
export { CreateApiKeyModal } from "./components/create-api-key-modal";
export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
export { UpdateApiKeyModal } from "./components/update-api-key-modal";
export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
// Services
export * from "./services/api-key-service";
// Types
export * from "./types/api-key.types";
@@ -0,0 +1,106 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}
@@ -0,0 +1,117 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items}
isLoading={isLoading}
showUserColumn
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}
@@ -0,0 +1,97 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createApiKey,
getApiKeys,
IApiKey,
ICreateApiKeyRequest,
IUpdateApiKeyRequest,
revokeApiKey,
updateApiKey,
} from "@/ee/api-key";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetApiKeysQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IApiKey>, Error> {
return useQuery({
queryKey: ["api-key-list", params],
queryFn: () => getApiKeys(params),
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useRevokeApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
Error,
{
apiKeyId: string;
}
>({
mutationFn: (data) => revokeApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, IUpdateApiKeyRequest>({
mutationFn: (data) => updateApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,32 @@
import api from "@/lib/api-client";
import {
ICreateApiKeyRequest,
IApiKey,
IUpdateApiKeyRequest,
} from "@/ee/api-key/types/api-key.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getApiKeys(
params?: QueryParams,
): Promise<IPagination<IApiKey>> {
const req = await api.post("/api-keys", { ...params });
return req.data;
}
export async function createApiKey(
data: ICreateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/create", data);
return req.data;
}
export async function updateApiKey(
data: IUpdateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/update", data);
return req.data;
}
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
await api.post("/api-keys/revoke", data);
}
@@ -0,0 +1,23 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IApiKey {
id: string;
name: string;
token?: string;
creatorId: string;
workspaceId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
creator: Partial<IUser>;
}
export interface ICreateApiKeyRequest {
name: string;
expiresAt?: string;
}
export interface IUpdateApiKeyRequest {
apiKeyId: string;
name: string;
}
@@ -0,0 +1,124 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
password: z.string().min(1, { message: "Password is required" }),
});
interface LdapLoginModalProps {
opened: boolean;
onClose: () => void;
provider: IAuthProvider;
workspaceId: string;
}
export function LdapLoginModal({
opened,
onClose,
provider,
workspaceId,
}: LdapLoginModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
username: "",
password: "",
},
});
const handleSubmit = async (values: {
username: string;
password: string;
}) => {
setIsLoading(true);
setError(null);
try {
const response = await ldapLogin({
username: values.username,
password: values.password,
providerId: provider.id,
workspaceId,
});
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
onClose();
navigate(APP_ROUTE.HOME);
}
} catch (err: any) {
setIsLoading(false);
const errorMessage =
err.response?.data?.message || "Authentication failed";
setError(errorMessage);
notifications.show({
message: errorMessage,
color: "red",
});
}
};
const handleClose = () => {
form.reset();
setError(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={`LDAP Login - ${provider.name}`}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
id="ldap-username"
type="text"
label={t("LDAP username")}
placeholder="Enter your LDAP username"
variant="filled"
disabled={isLoading}
data-autofocus
{...form.getInputProps("username")}
/>
<PasswordInput
label={t("LDAP password")}
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign in with LDAP")}
</Button>
</Stack>
</form>
</Modal>
);
}
+40 -13
View File
@@ -1,29 +1,62 @@
import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core"; import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react"; import { IconLock, IconServer } from "@tabler/icons-react";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { 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) => {
window.location.href = buildSsoLoginUrl({ if (provider.type === SSO_PROVIDER.LDAP) {
providerId: provider.id, // Open modal for LDAP instead of redirecting
type: provider.type, setSelectedLdapProvider(provider);
workspaceId: data.id, setLdapModalOpened(true);
}); } else {
// Redirect for other SSO providers
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
}
};
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
} else if (provider.type === SSO_PROVIDER.LDAP) {
return <IconServer size={16} />;
} else {
return <IconLock size={16} />;
}
}; };
return ( 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">
@@ -31,13 +64,7 @@ export default function SsoLogin() {
<div key={provider.id}> <div key={provider.id}>
<Button <Button
onClick={() => handleSsoLogin(provider)} onClick={() => handleSsoLogin(provider)}
leftSection={ leftSection={getProviderIcon(provider)}
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
variant="default" variant="default"
fullWidth fullWidth
> >
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder withTableBorder
> >
<Table.Caption> <Table.Caption>
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com. To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption> </Table.Caption>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
@@ -45,6 +45,7 @@ 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,23 +25,30 @@ 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),
@@ -51,7 +58,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);
@@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
}, },
}); });
const handleRegenerate = (values: { confirmPassword: string }) => { const handleRegenerate = (values: { confirmPassword?: string }) => {
regenerateMutation.mutate(values); // Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
regenerateMutation.mutate(payload);
}; };
const handleClose = () => { const handleClose = () => {
@@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
)} )}
</Text> </Text>
<PasswordInput {requiresPassword && (
label={t("Confirm password")} <PasswordInput
placeholder={t("Enter your password")} label={t("Confirm password")}
variant="filled" placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")} variant="filled"
/> {...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
)}
<Button <Button
type="submit" type="submit"
@@ -97,6 +97,7 @@ 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,6 +15,7 @@ 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;
@@ -22,16 +23,22 @@ 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),
@@ -54,8 +61,12 @@ export function MfaDisableModal({
}, },
}); });
const handleSubmit = async (values: { confirmPassword: string }) => { const handleSubmit = async (values: { confirmPassword?: string }) => {
await disableMutation.mutateAsync(values); // Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
}; };
const handleClose = () => { const handleClose = () => {
@@ -85,18 +96,23 @@ export function MfaDisableModal({
</Text> </Text>
</Alert> </Alert>
<Text size="sm"> {requiresPassword && (
{t( <>
"Please enter your password to disable two-factor authentication:", <Text size="sm">
)} {t(
</Text> "Please enter your password to disable two-factor authentication:",
)}
</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,6 +9,7 @@ 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();
@@ -53,8 +54,8 @@ export function MfaSettings() {
return ( return (
<> <>
<Group justify="space-between" wrap="nowrap" gap="xl"> <ResponsiveSettingsRow>
<div style={{ minWidth: 0, flex: 1 }}> <ResponsiveSettingsContent>
<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
@@ -63,44 +64,46 @@ export function MfaSettings() {
) )
: t("Two-factor authentication is active on your account.")} : t("Two-factor authentication is active on your account.")}
</Text> </Text>
</div> </ResponsiveSettingsContent>
{!isMfaEnabled ? ( <ResponsiveSettingsControl>
<Tooltip {!isMfaEnabled ? (
label={t("Available in enterprise edition")} <Tooltip
disabled={canUseMfa} label={t("Available in enterprise edition")}
> disabled={canUseMfa}
<Button
disabled={!canUseMfa}
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
> >
{t("Add 2FA method")} <Button
</Button> disabled={!canUseMfa}
</Tooltip> variant="default"
) : ( onClick={() => setSetupModalOpen(true)}
<Group gap="sm" wrap="nowrap"> style={{ whiteSpace: "nowrap" }}
<Button >
variant="default" {t("Add 2FA method")}
size="sm" </Button>
onClick={() => setBackupCodesModalOpen(true)} </Tooltip>
style={{ whiteSpace: "nowrap" }} ) : (
> <Group gap="sm" wrap="nowrap">
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0}) <Button
</Button> variant="default"
<Button size="sm"
variant="default" onClick={() => setBackupCodesModalOpen(true)}
size="sm" style={{ whiteSpace: "nowrap" }}
color="red" >
onClick={() => setDisableModalOpen(true)} {t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
style={{ whiteSpace: "nowrap" }} </Button>
> <Button
{t("Disable")} variant="default"
</Button> size="sm"
</Group> color="red"
)} onClick={() => setDisableModalOpen(true)}
</Group> style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<MfaSetupModal <MfaSetupModal
opened={setupModalOpen} opened={setupModalOpen}
@@ -235,6 +235,7 @@ 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",
+1 -1
View File
@@ -46,7 +46,7 @@ export interface MfaEnableResponse {
} }
export interface MfaDisableRequest { export interface MfaDisableRequest {
confirmPassword: string; confirmPassword?: string;
} }
export interface MfaBackupCodesResponse { export interface MfaBackupCodesResponse {
@@ -1,6 +1,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import * as z from "zod"; import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { 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";
@@ -54,9 +55,11 @@ export default function AllowedDomains() {
return ( return (
<> <>
<div> <div>
<Text size="md">Allowed email domains</Text> <Text size="md">{t("Allowed email domains")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Only users with email addresses from these domains can signup via SSO. {t(
"Only users with email addresses from these domains can signup via SSO.",
)}
</Text> </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 } from "@tabler/icons-react"; import { IconChevronDown, IconLock, IconServer } 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,6 +40,19 @@ export default function CreateSsoProvider() {
} }
}; };
const handleCreateLDAP = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.LDAP,
name: "LDAP",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create LDAP provider", error);
}
};
return ( return (
<> <>
<SsoProviderModal opened={opened} onClose={close} provider={provider} /> <SsoProviderModal opened={opened} onClose={close} provider={provider} />
@@ -71,6 +84,13 @@ 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,6 +1,7 @@
import React from "react"; import React from "react";
import { z } from "zod"; import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core"; import { 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";
@@ -0,0 +1,228 @@
import React from "react";
import { z } from "zod";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
Group,
Stack,
Switch,
TextInput,
Textarea,
Text,
Accordion,
} from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { IconInfoCircle } from "@tabler/icons-react";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
ldapUrl: z.string().url().startsWith("ldap", "Must be an LDAP URL"),
ldapBindDn: z.string().min(1, "Bind DN is required"),
ldapBindPassword: z.string().min(1, "Bind password is required"),
ldapBaseDn: z.string().min(1, "Base DN is required"),
ldapUserSearchFilter: z.string().optional(),
ldapTlsEnabled: z.boolean(),
ldapTlsCaCert: z.string().optional(),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
ldapUrl: provider.ldapUrl || "",
ldapBindDn: provider.ldapBindDn || "",
ldapBindPassword: provider.ldapBindPassword || "",
ldapBaseDn: provider.ldapBaseDn || "",
ldapUserSearchFilter:
provider.ldapUserSearchFilter || "(mail={{username}})",
ldapTlsEnabled: provider.ldapTlsEnabled || false,
ldapTlsCaCert: provider.ldapTlsCaCert || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("ldapUrl")) {
ssoData.ldapUrl = values.ldapUrl;
}
if (form.isDirty("ldapBindDn")) {
ssoData.ldapBindDn = values.ldapBindDn;
}
if (form.isDirty("ldapBindPassword")) {
ssoData.ldapBindPassword = values.ldapBindPassword;
}
if (form.isDirty("ldapBaseDn")) {
ssoData.ldapBaseDn = values.ldapBaseDn;
}
if (form.isDirty("ldapUserSearchFilter")) {
ssoData.ldapUserSearchFilter = values.ldapUserSearchFilter;
}
if (form.isDirty("ldapTlsEnabled")) {
ssoData.ldapTlsEnabled = values.ldapTlsEnabled;
}
if (form.isDirty("ldapTlsCaCert")) {
ssoData.ldapTlsCaCert = values.ldapTlsCaCert;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("Display name")}
placeholder="e.g Company LDAP"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="LDAP Server URL"
description="URL of your LDAP server"
placeholder="ldap://ldap.example.com:389 or ldaps://ldap.example.com:636"
{...form.getInputProps("ldapUrl")}
/>
<TextInput
label="Bind DN"
description="Distinguished Name of the service account for searching"
placeholder="cn=admin,dc=example,dc=com"
{...form.getInputProps("ldapBindDn")}
/>
<TextInput
label="Bind Password"
description="Password for the service account"
type="password"
placeholder="••••••••"
{...form.getInputProps("ldapBindPassword")}
/>
<TextInput
label="Base DN"
description="Base DN where user searches will start"
placeholder="ou=users,dc=example,dc=com"
{...form.getInputProps("ldapBaseDn")}
/>
<TextInput
label="User Search Filter"
description="LDAP filter to find users. Use {{username}} as placeholder"
placeholder="(mail={{username}})"
{...form.getInputProps("ldapUserSearchFilter")}
/>
<Accordion variant="separated">
<Accordion.Item value="advanced">
<Accordion.Control icon={<IconInfoCircle size={20} />}>
{t("Advanced Settings")}
</Accordion.Control>
<Accordion.Panel>
<Stack>
<Group justify="space-between">
<div>
<Text size="sm">{t("Enable TLS/SSL")}</Text>
<Text size="xs" c="dimmed">
Use secure connection to LDAP server
</Text>
</div>
<Switch
className={classes.switch}
checked={form.values.ldapTlsEnabled}
{...form.getInputProps("ldapTlsEnabled")}
/>
</Group>
{form.values.ldapTlsEnabled && (
<Textarea
label="CA Certificate"
description="PEM-encoded CA certificate for TLS verification (optional)"
placeholder="-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----"
minRows={4}
{...form.getInputProps("ldapTlsCaCert")}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}
@@ -16,6 +16,7 @@ const ssoSchema = z.object({
oidcClientSecret: z.string().min(1, "Client secret is required"), 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>;
@@ -36,6 +37,7 @@ 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),
}); });
@@ -67,6 +69,9 @@ 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();
@@ -78,7 +83,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput <TextInput
label="Display name" label={t("Display name")}
placeholder="e.g Google SSO" placeholder="e.g Google SSO"
data-autofocus data-autofocus
{...form.getInputProps("name")} {...form.getInputProps("name")}
@@ -110,6 +115,15 @@ 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={500}> <Table.ScrollContainer minWidth={600}>
<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"> <Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
{provider.type.toUpperCase()} {provider.type.toUpperCase()}
</Badge> </Badge>
</Table.Td> </Table.Td>
@@ -133,6 +133,7 @@ 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"
@@ -168,6 +169,7 @@ export default function SsoProviderList() {
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -5,6 +5,8 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { 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;
@@ -17,6 +19,8 @@ export default function SsoProviderModal({
onClose, onClose,
provider, provider,
}: SsoModalProps) { }: SsoModalProps) {
const { t } = useTranslation();
if (!provider) { if (!provider) {
return null; return null;
} }
@@ -24,7 +28,9 @@ export default function SsoProviderModal({
return ( return (
<Modal <Modal
opened={opened} opened={opened}
title={`${provider.type.toUpperCase()} Configuration`} title={t("{{ssoProviderType}} configuration", {
ssoProviderType: provider.type.toUpperCase(),
})}
onClose={onClose} onClose={onClose}
> >
{provider.type === SSO_PROVIDER.SAML && ( {provider.type === SSO_PROVIDER.SAML && (
@@ -38,6 +44,10 @@ 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,6 +1,7 @@
import React from "react"; import React from "react";
import { z } from "zod"; import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { import {
Box, Box,
Button, Button,
@@ -26,6 +27,7 @@ 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>;
@@ -45,6 +47,7 @@ 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),
}); });
@@ -75,6 +78,9 @@ 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();
@@ -86,7 +92,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput <TextInput
label="Display name" label={t("Display name")}
placeholder="e.g Azure Entra" placeholder="e.g Azure Entra"
data-autofocus data-autofocus
{...form.getInputProps("name")} {...form.getInputProps("name")}
@@ -123,6 +129,15 @@ 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
+1
View File
@@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
OIDC = 'oidc', OIDC = 'oidc',
SAML = 'saml', SAML = 'saml',
GOOGLE = 'google', GOOGLE = 'google',
LDAP = 'ldap',
} }
@@ -0,0 +1,23 @@
import api from "@/lib/api-client.ts";
import { ILoginResponse } from "@/features/auth/types/auth.types.ts";
interface ILdapLogin {
username: string;
password: string;
providerId: string;
workspaceId: string;
}
export async function ldapLogin(data: ILdapLogin): Promise<ILoginResponse> {
const requestData = {
username: data.username,
password: data.password,
};
const response = await api.post<ILoginResponse>(
`/sso/ldap/${data.providerId}/login`,
requestData
);
return response.data;
}
@@ -9,8 +9,17 @@ export interface IAuthProvider {
oidcIssuer: string; 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;
@@ -0,0 +1,64 @@
import api from "@/lib/api-client";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
return await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.AVATAR);
}
export async function uploadSpaceIcon(
file: File,
spaceId: string,
): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
}
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
}
async function removeIcon(
type: AvatarIconType,
spaceId?: string,
): Promise<void> {
const payload: { spaceId?: string; type: string } = { type };
if (spaceId) {
payload.spaceId = spaceId;
}
await api.post("/attachments/remove-icon", payload);
}
export async function removeAvatar(): Promise<void> {
await removeIcon(AvatarIconType.AVATAR);
}
export async function removeSpaceIcon(spaceId: string): Promise<void> {
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
}
export async function removeWorkspaceIcon(): Promise<void> {
await removeIcon(AvatarIconType.WORKSPACE_ICON);
}
@@ -0,0 +1,9 @@
export {
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
uploadWorkspaceIcon,
removeAvatar,
removeSpaceIcon,
removeWorkspaceIcon,
} from "./attachment-service.ts";
@@ -0,0 +1,29 @@
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
WORKSPACE_ICON = "workspace-icon",
}
export enum AttachmentType {
AVATAR = "avatar",
WORKSPACE_ICON = "workspace-icon",
SPACE_ICON = "space-icon",
FILE = "file",
}
@@ -3,6 +3,7 @@ import {
BubbleMenuProps, BubbleMenuProps,
isNodeSelection, isNodeSelection,
useEditor, useEditor,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
@@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
if (!props.editor) {
return null;
}
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Bold", name: "Bold",
isActive: () => props.editor.isActive("bold"), isActive: () => editorState?.isBold,
command: () => props.editor.chain().focus().toggleBold().run(), command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold, icon: IconBold,
}, },
{ {
name: "Italic", name: "Italic",
isActive: () => props.editor.isActive("italic"), isActive: () => editorState?.isItalic,
command: () => props.editor.chain().focus().toggleItalic().run(), command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic, icon: IconItalic,
}, },
{ {
name: "Underline", name: "Underline",
isActive: () => props.editor.isActive("underline"), isActive: () => editorState?.isUnderline,
command: () => props.editor.chain().focus().toggleUnderline().run(), command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline, icon: IconUnderline,
}, },
{ {
name: "Strike", name: "Strike",
isActive: () => props.editor.isActive("strike"), isActive: () => editorState?.isStrike,
command: () => props.editor.chain().focus().toggleStrike().run(), command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough, icon: IconStrikethrough,
}, },
{ {
name: "Code", name: "Code",
isActive: () => props.editor.isActive("code"), isActive: () => editorState?.isCode,
command: () => props.editor.chain().focus().toggleCode().run(), command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode, icon: IconCode,
}, },
@@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = { const commentItem: BubbleMenuItem = {
name: "Comment", name: "Comment",
isActive: () => props.editor.isActive("comment"), isActive: () => editorState?.isComment,
command: () => { command: () => {
const commentId = uuid7(); const commentId = uuid7();
@@ -125,16 +144,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false); setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}, },
}, },
}; };
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps}> <BubbleMenu {...bubbleMenuProps}>
@@ -145,8 +164,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false); setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}} }}
/> />
@@ -156,8 +175,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false); setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}} }}
/> />
@@ -1,5 +1,5 @@
import { Dispatch, FC, SetStateAction } from "react"; import React, { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconPalette } from "@tabler/icons-react"; import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
import { import {
ActionIcon, ActionIcon,
Button, Button,
@@ -8,8 +8,12 @@ import {
ScrollArea, ScrollArea,
Text, Text,
Tooltip, Tooltip,
SimpleGrid,
Box,
Stack,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
@@ -18,7 +22,7 @@ export interface BubbleColorMenuItem {
} }
interface ColorSelectorProps { interface ColorSelectorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -60,9 +64,12 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
name: "Gray", name: "Gray",
color: "#A8A29E", color: "#A8A29E",
}, },
{
name: "Brown",
color: "#92400E",
},
]; ];
// TODO: handle dark mode
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{ {
name: "Default", name: "Default",
@@ -70,35 +77,39 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
}, },
{ {
name: "Blue", name: "Blue",
color: "#c1ecf9", color: "#98d8f2",
}, },
{ {
name: "Green", name: "Green",
color: "#acf79f", color: "#7edb6c",
}, },
{ {
name: "Purple", name: "Purple",
color: "#f6f3f8", color: "#e0d6ed",
}, },
{ {
name: "Red", name: "Red",
color: "#fdebeb", color: "#ffc6c2",
}, },
{ {
name: "Yellow", name: "Yellow",
color: "#fbf4a2", color: "#faf594",
}, },
{ {
name: "Orange", name: "Orange",
color: "#faebdd", color: "#f5c8a9",
}, },
{ {
name: "Pink", name: "Pink",
color: "#faf1f5", color: "#f5cfe0",
}, },
{ {
name: "Gray", name: "Gray",
color: "#f1f1ef", color: "#dfdfd7",
},
{
name: "Brown",
color: "#d7c4b7",
}, },
]; ];
@@ -108,67 +119,180 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen, setIsOpen,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }), const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", {
color,
});
});
HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", {
color,
});
});
return activeColors;
},
});
if (!editor || !editorState) {
return null;
}
const activeColorItem = TEXT_COLORS.find(
({ color }) => editorState[`text_${color}`],
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => const activeHighlightItem = HIGHLIGHT_COLORS.find(
editor.isActive("highlight", { color }), ({ color }) => editorState[`highlight_${color}`],
); );
return ( return (
<Popover width={200} opened={isOpen} withArrow> <Popover width={220} opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow>
<ActionIcon <Button
variant="default" variant="default"
size="lg"
radius="0" radius="0"
style={{ rightSection={<IconChevronDown size={16} />}
border: "none",
color: activeColorItem?.color,
}}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger"
style={{
height: "34px",
border: "none",
fontWeight: 500,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}}
> >
<IconPalette size={16} stroke={2} /> A
</ActionIcon> </Button>
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400"> <ScrollArea.Autosize type="scroll" mah="400">
<Text span c="dimmed" tt="uppercase" inherit> <Stack gap="md">
{t("Color")} <Box>
</Text> <Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
}}
style={{
width: rem(28),
height: rem(28),
borderRadius: rem(6),
border: editorState[`text_${color}`]
? "2px solid var(--mantine-color-gray-8)"
: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: color || "var(--mantine-color-gray-8)",
}}
>
A
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Button.Group orientation="vertical"> <Box>
{TEXT_COLORS.map(({ name, color }, index) => ( <Text size="sm" fw={600} mb="xs">
<Button {t("Highlight color")}
key={index} </Text>
variant="default" <SimpleGrid cols={5} spacing="xs">
leftSection={<span style={{ color }}>A</span>} {HIGHLIGHT_COLORS.map(({ name, color }, index) => (
justify="left" <Tooltip key={index} label={t(name)} withArrow>
fullWidth <Box
rightSection={ onClick={() => {
editor.isActive("textStyle", { color }) && ( if (name === "Default") {
<IconCheck style={{ width: rem(16) }} /> editor.commands.unsetHighlight();
) } else {
} editor
onClick={() => { .chain()
if (name === "Default") { .focus()
editor.commands.unsetColor(); .toggleMark("highlight", {
} else { color: color || "",
editor.chain().focus().setColor(color || "").run(); colorName: name.toLowerCase() || "",
} })
setIsOpen(false); .run();
}} }
style={{ border: "none" }} setIsOpen(false);
> }}
{t(name)} style={{
</Button> width: rem(28),
))} height: rem(28),
</Button.Group> borderRadius: rem(4),
backgroundColor: color || "var(--mantine-color-gray-2)",
border: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: "var(--mantine-color-gray-8)",
}}
>
{editorState[`highlight_${color}`] ? (
<IconCheck
size={16}
color="var(--mantine-color-green-7)"
/>
) : (
"A"
)}
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
onClick={() => {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
setIsOpen(false);
}}
>
{t("Remove color")}
</Button>
</Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -13,11 +13,12 @@ import {
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core"; import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -36,6 +37,27 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!editor) {
return null;
}
return {
isParagraph: ctx.editor.isActive("paragraph"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Text", name: "Text",
@@ -43,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(), editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => isActive: () =>
editor.isActive("paragraph") && editorState?.isParagraph &&
!editor.isActive("bulletList") && !editorState?.isBulletList &&
!editor.isActive("orderedList"), !editorState?.isOrderedList,
}, },
{ {
name: "Heading 1", name: "Heading 1",
icon: IconH1, icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }), isActive: () => editorState?.isHeading1,
}, },
{ {
name: "Heading 2", name: "Heading 2",
icon: IconH2, icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }), isActive: () => editorState?.isHeading2,
}, },
{ {
name: "Heading 3", name: "Heading 3",
icon: IconH3, icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }), isActive: () => editorState?.isHeading3,
}, },
{ {
name: "To-do List", name: "To-do List",
icon: IconCheckbox, icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(), command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"), isActive: () => editorState?.isTaskItem,
}, },
{ {
name: "Bullet List", name: "Bullet List",
icon: IconList, icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(), command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"), isActive: () => editorState?.isBulletList,
}, },
{ {
name: "Numbered List", name: "Numbered List",
icon: IconListNumbers, icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(), command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"), isActive: () => editorState?.isOrderedList,
}, },
{ {
name: "Blockquote", name: "Blockquote",
@@ -93,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph") .toggleNode("paragraph", "paragraph")
.toggleBlockquote() .toggleBlockquote()
.run(), .run(),
isActive: () => editor.isActive("blockquote"), isActive: () => editorState?.isBlockquote,
}, },
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"), isActive: () => editorState?.isCodeBlock,
}, },
]; ];
@@ -8,11 +8,12 @@ import {
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core"; import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TextAlignmentProps { interface TextAlignmentProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Align left", name: "Align left",
isActive: () => editor.isActive({ textAlign: "left" }), isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
isActive: () => editor.isActive({ textAlign: "center" }), isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
isActive: () => editor.isActive({ textAlign: "right" }), isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
{ {
name: "Justify", name: "Justify",
isActive: () => editor.isActive({ textAlign: "justify" }), isActive: () => editorState?.isAlignJustify,
command: () => editor.chain().focus().setTextAlign("justify").run(), command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified, icon: IconAlignJustified,
}, },
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? { const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
name: "Multiple",
};
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
@@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<IconAlignLeft style={{ width: rem(16) }} stroke={2} /> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
</Popover.Target> </Popover.Target>
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
@@ -15,12 +16,15 @@ import {
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) {
@@ -32,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
@@ -56,10 +77,40 @@ 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}
pluginKey={`callout-menu}`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -78,9 +129,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")} onClick={() => setCalloutType("info")}
size="lg" size="lg"
aria-label={t("Info")} aria-label={t("Info")}
variant={ variant={editorState?.isInfo ? "light" : "default"}
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
> >
<IconInfoCircleFilled size={18} /> <IconInfoCircleFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -91,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")} onClick={() => setCalloutType("success")}
size="lg" size="lg"
aria-label={t("Success")} aria-label={t("Success")}
variant={ variant={editorState?.isSuccess ? "light" : "default"}
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
> >
<IconCircleCheckFilled size={18} /> <IconCircleCheckFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -106,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")} onClick={() => setCalloutType("warning")}
size="lg" size="lg"
aria-label={t("Warning")} aria-label={t("Warning")}
variant={ variant={editorState?.isWarning ? "light" : "default"}
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
> >
<IconAlertTriangleFilled size={18} /> <IconAlertTriangleFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -121,15 +162,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")} onClick={() => setCalloutType("danger")}
size="lg" size="lg"
aria-label={t("Danger")} aria-label={t("Danger")}
variant={ variant={editorState?.isDanger ? "light" : "default"}
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
> >
<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 } = node.attrs; const { type, icon } = 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={getCalloutIcon(type, icon)}
p="xs" p="xs"
classNames={{ classNames={{
message: classes.message, message: classes.message,
@@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) {
); );
} }
function getCalloutIcon(type: CalloutType) { function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
}
switch (type) { switch (type) {
case "info": case "info":
return <IconInfoCircleFilled />; return <IconInfoCircleFilled />;
@@ -4,11 +4,8 @@ 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({ import DOMPurify from "dompurify";
startOnLoad: false,
suppressErrorRendering: true,
});
interface MermaidViewProps { interface MermaidViewProps {
props: NodeViewProps; props: NodeViewProps;
@@ -16,12 +13,22 @@ 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)
@@ -31,7 +38,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => { .catch((err) => {
if (props.editor.isEditable) { if (props.editor.isEditable) {
setPreview( setPreview(
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`, `<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`,
); );
} else { } else {
setPreview( setPreview(
@@ -40,7 +47,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
} }
}); });
} }
}, [node.textContent]); }, [node.textContent, computedColorScheme]);
return ( return (
<div <div
@@ -34,7 +34,9 @@ export const handlePaste = (
return false; return false;
} }
createMentionAction(url, view, pos, creatorId); const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true; return true;
} }
@@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
} from '@tiptap/react'; useEditorState,
import { useCallback } from 'react'; } from "@tiptap/react";
import { sticky } from 'tippy.js'; import { useCallback } from "react";
import { Node as PMNode } from 'prosemirror-model'; import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts'; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src; return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
}, },
[editor] [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'drawio'; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` }); editor.commands.updateAttributes("drawio", { width: `${value}%` });
}, },
[editor] [editor],
); );
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`drawio-menu}`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: "flip", enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: 'popper', sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}} }}
> >
{editor.getAttributes('drawio')?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
@@ -17,7 +17,7 @@ import {
EventExit, EventExit,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { IAttachment } from "@/lib/types"; import { IAttachment } from "@/features/attachments/types/attachment.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";
@@ -87,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -145,6 +145,7 @@ 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,
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
{embedUrl ? ( {embedUrl ? (
<ResizableWrapper <ResizableWrapper
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 480}
@@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
} from '@tiptap/react'; useEditorState,
import { useCallback } from 'react'; } from "@tiptap/react";
import { sticky } from 'tippy.js'; import { useCallback } from "react";
import { Node as PMNode } from 'prosemirror-model'; import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts'; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src; return (
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
);
}, },
[editor] [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'excalidraw'; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes('excalidraw', { width: `${value}%` }); editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
}, },
[editor] [editor],
); );
return ( return (
@@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: "flip", enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: 'popper', sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}} }}
> >
{editor.getAttributes('excalidraw')?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
@@ -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 "@/lib/types"; import { IAttachment } from "@/features/attachments/types/attachment.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";
@@ -118,7 +118,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<ReactClearModal <ReactClearModal
style={{ style={{
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -183,6 +183,7 @@ 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,
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
isImage: ctx.editor.isActive("image"),
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
@@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`image-menu}`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -103,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft} onClick={alignImageLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={ variant={editorState?.isAlignLeft ? "light" : "default"}
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter} onClick={alignImageCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={ variant={editorState?.isAlignCenter ? "light" : "default"}
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight} onClick={alignImageRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={ variant={editorState?.isAlignRight ? "light" : "default"}
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editor.getAttributes("image")?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<Image <Image
radius="md" radius="md"
fit="contain" fit="contain"
@@ -9,6 +9,7 @@ export type LinkFn = (
view: EditorView, view: EditorView,
pos: number, pos: number,
creatorId: string, creatorId: string,
anchorId?: string,
) => void; ) => void;
export interface InternalLinkOptions { export interface InternalLinkOptions {
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
export const handleInternalLink = export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId) => { async (url: string, view, pos, creatorId, anchorId) => {
const validated = validateFn(url, view); const validated = validateFn(url, view);
if (!validated) return; if (!validated) return;
@@ -35,6 +36,7 @@ export const handleInternalLink =
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
creatorId: creatorId, creatorId: creatorId,
anchorId: anchorId,
}); });
if (!node) return; if (!node) return;
@@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@@ -12,7 +12,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link"); return editor.isActive("link");
}, [editor]); }, [editor]);
const { href: link } = editor.getAttributes("link"); const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
setShowEdit(true); setShowEdit(true);
@@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs" padding="xs"
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
> >
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} /> <LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card> </Card>
) : ( ) : (
<LinkPreviewPanel <LinkPreviewPanel
url={link} url={editorState?.href}
onClear={onUnsetLink} onClear={onUnsetLink}
onEdit={handleEdit} onEdit={handleEdit}
/> />
@@ -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 } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const { const {
@@ -27,10 +27,11 @@ export default function MentionView(props: NodeViewProps) {
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: label,
anchorId,
}); });
return ( return (
<NodeViewWrapper style={{ display: "inline" }}> <NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
{entityType === "user" && ( {entityType === "user" && (
<Text className={classes.userMention} component="span"> <Text className={classes.userMention} component="span">
@{label} @{label}
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
} }
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -17,8 +17,10 @@ import {
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4, IconMenu4,
IconCalendar, IconAppWindow, IconCalendar,
} from '@tabler/icons-react'; IconAppWindow,
IconSitemap,
} from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
SlashMenuGroupedItemsType, SlashMenuGroupedItemsType,
@@ -357,6 +359,15 @@ 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",
@@ -0,0 +1,95 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
}
interface ShouldShowProps {
state: any;
from?: number;
to?: number;
}
export const SubpagesMenu = React.memo(
({ editor }: SubpagesMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("subpages");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
.chain()
.focus()
.setNodeSelection(selection.from)
.deleteSelection()
.run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</BaseBubbleMenu>
);
},
);
export default SubpagesMenu;
@@ -0,0 +1,120 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({
pageId: currentPageId,
});
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
if (shareId && sharedSubpages) {
return sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]);
if (isLoading && !shareId) {
return null;
}
if (error && !shareId) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (subpages.length === 0) {
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle>
<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>
);
}

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