mirror of
https://github.com/docmost/docmost.git
synced 2026-05-12 18:04:06 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d51342f7b0 | |||
| 7d034e8a8b | |||
| 81b6c7ef69 | |||
| 89f6b0a8c2 | |||
| ad1571b902 | |||
| 4b9ab4f63c | |||
| 08829ea721 | |||
| 6c502b4749 | |||
| 6b41538b60 | |||
| 496f5d7384 | |||
| 32c7a16d06 | |||
| 64ecef09bc | |||
| 3e5cb92621 | |||
| fd5ad2f576 | |||
| 74a5360561 | |||
| 7580e8d1fe | |||
| f92d63261d | |||
| 4d51986250 | |||
| e209aaa272 | |||
| 0ef6b1978a | |||
| ae842f94d0 | |||
| 7121771f92 | |||
| 040d6625df | |||
| 33ddd92198 | |||
| 54e8d60840 | |||
| db986038c2 | |||
| de0b5f0046 | |||
| 638b811857 | |||
| d775a61c95 | |||
| 0f74f03264 | |||
| f8b93ce93f | |||
| 85d18b8cc8 | |||
| 4d9fe6f804 | |||
| 85159a2c95 | |||
| 990612793f | |||
| f2235fd2a2 |
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# make sure to replace this.
|
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||||
|
|
||||||
JWT_TOKEN_EXPIRES_IN=30d
|
JWT_TOKEN_EXPIRES_IN=30d
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.8.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -24,9 +24,8 @@
|
|||||||
"@mantine/spotlight": "^7.14.2",
|
"@mantine/spotlight": "^7.14.2",
|
||||||
"@tabler/icons-react": "^3.22.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.61.4",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"axios": "^1.7.8",
|
"axios": "^1.7.9",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
@@ -34,12 +33,11 @@
|
|||||||
"jotai": "^2.10.3",
|
"jotai": "^2.10.3",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"katex": "0.16.21",
|
||||||
"katex": "^0.16.11",
|
|
||||||
"lowlight": "^3.2.0",
|
"lowlight": "^3.2.0",
|
||||||
"mermaid": "^11.4.0",
|
"mermaid": "^11.4.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "^3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.11",
|
"react-clear-modal": "^2.0.11",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^1.0.1",
|
"react-drawio": "^1.0.1",
|
||||||
@@ -74,6 +72,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.0.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,7 @@
|
|||||||
"Align left": "Align left",
|
"Align left": "Align left",
|
||||||
"Align right": "Align right",
|
"Align right": "Align right",
|
||||||
"Align center": "Align center",
|
"Align center": "Align center",
|
||||||
|
"Justify": "Justify",
|
||||||
"Merge cells": "Merge cells",
|
"Merge cells": "Merge cells",
|
||||||
"Split cell": "Split cell",
|
"Split cell": "Split cell",
|
||||||
"Delete column": "Delete column",
|
"Delete column": "Delete column",
|
||||||
|
|||||||
@@ -1,342 +1,342 @@
|
|||||||
{
|
{
|
||||||
"Account": "Account",
|
"Account": "Cuenta",
|
||||||
"Active": "Active",
|
"Active": "Activo",
|
||||||
"Add": "Add",
|
"Add": "Agregar",
|
||||||
"Add group members": "Add group members",
|
"Add group members": "Agregar miembros del grupo",
|
||||||
"Add groups": "Add groups",
|
"Add groups": "Agregar grupos",
|
||||||
"Add members": "Add members",
|
"Add members": "Agregar miembros",
|
||||||
"Add to groups": "Add to groups",
|
"Add to groups": "Agregar a grupos",
|
||||||
"Add space members": "Add space members",
|
"Add space members": "Agregar miembros al espacio",
|
||||||
"Admin": "Admin",
|
"Admin": "Administrador",
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "¿Estás seguro de que deseas eliminar este grupo? Los miembros perderán acceso a los recursos a los que este grupo tiene acceso.",
|
||||||
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
"Are you sure you want to delete this page?": "¿Está seguro de que desea eliminar esta página?",
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "¿Está seguro de que desea eliminar a este usuario del grupo? El usuario perderá acceso a los recursos a los que tiene acceso este grupo.",
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "¿Está seguro de que desea eliminar a este usuario del espacio? El usuario perderá todo acceso a este espacio.",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "¿Está seguro de que desea restaurar esta versión? Cualquier cambio no versionado se perderá.",
|
||||||
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
"Can become members of groups and spaces in workspace": "Pueden convertirse en miembros de grupos y espacios en el espacio de trabajo",
|
||||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
"Can create and edit pages in space.": "Puede crear y editar páginas en el espacio.",
|
||||||
"Can edit": "Can edit",
|
"Can edit": "Puede editar",
|
||||||
"Can manage workspace": "Can manage workspace",
|
"Can manage workspace": "Puede gestionar el espacio de trabajo",
|
||||||
"Can manage workspace but cannot delete it": "Can manage workspace but cannot delete it",
|
"Can manage workspace but cannot delete it": "Puede gestionar el espacio de trabajo pero no puede eliminarlo",
|
||||||
"Can view": "Can view",
|
"Can view": "Puede ver",
|
||||||
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
"Can view pages in space but not edit.": "Puede ver páginas en el espacio pero no editarlas.",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancelar",
|
||||||
"Change email": "Change email",
|
"Change email": "Cambiar correo electrónico",
|
||||||
"Change password": "Change password",
|
"Change password": "Cambiar contraseña",
|
||||||
"Change photo": "Change photo",
|
"Change photo": "Cambiar foto",
|
||||||
"Choose a role": "Choose a role",
|
"Choose a role": "Seleccione un rol",
|
||||||
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
"Choose your preferred color scheme.": "Elige tu esquema de color preferido.",
|
||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirmar",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copiar enlace",
|
||||||
"Create": "Create",
|
"Create": "Crear",
|
||||||
"Create group": "Create group",
|
"Create group": "Crear grupo",
|
||||||
"Create page": "Create page",
|
"Create page": "Crear página",
|
||||||
"Create space": "Create space",
|
"Create space": "Crear espacio",
|
||||||
"Create workspace": "Create workspace",
|
"Create workspace": "Crear espacio de trabajo",
|
||||||
"Current password": "Current password",
|
"Current password": "Contraseña actual",
|
||||||
"Dark": "Dark",
|
"Dark": "Oscuro",
|
||||||
"Date": "Date",
|
"Date": "Fecha",
|
||||||
"Delete": "Delete",
|
"Delete": "Eliminar",
|
||||||
"Delete group": "Delete group",
|
"Delete group": "Eliminar grupo",
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "¿Está seguro de que desea eliminar esta página? Esto eliminará sus dependientes y el historial de la página. Esta acción es irreversible.",
|
||||||
"Description": "Description",
|
"Description": "Descripción",
|
||||||
"Details": "Details",
|
"Details": "Detalles",
|
||||||
"e.g ACME": "e.g ACME",
|
"e.g ACME": "ej: ACME",
|
||||||
"e.g ACME Inc": "e.g ACME Inc",
|
"e.g ACME Inc": "ej: ACME Inc",
|
||||||
"e.g Developers": "e.g Developers",
|
"e.g Developers": "ej: Desarrolladores",
|
||||||
"e.g Group for developers": "e.g Group for developers",
|
"e.g Group for developers": "ej: Grupo para desarrolladores",
|
||||||
"e.g product": "e.g product",
|
"e.g product": "ej: producto",
|
||||||
"e.g Product Team": "e.g Product Team",
|
"e.g Product Team": "ej: Equipo de Producto",
|
||||||
"e.g Sales": "e.g Sales",
|
"e.g Sales": "ej: Ventas",
|
||||||
"e.g Space for product team": "e.g Space for product team",
|
"e.g Space for product team": "ej: Espacio para el equipo de producto",
|
||||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
"e.g Space for sales team to collaborate": "ej: Espacio para que el equipo de ventas colabore",
|
||||||
"Edit": "Edit",
|
"Edit": "Editar",
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Editar grupo",
|
||||||
"Email": "Email",
|
"Email": "Correo electrónico",
|
||||||
"Enter a strong password": "Enter a strong password",
|
"Enter a strong password": "Introduce una contraseña fuerte",
|
||||||
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]",
|
"Enter valid email addresses separated by comma or space max_50": "Ingrese direcciones de correo electrónico válidas separadas por coma o espacio [max: 50]",
|
||||||
"enter valid emails addresses": "enter valid emails addresses",
|
"enter valid emails addresses": "introduce direcciones de correo electrónico válidas",
|
||||||
"Enter your current password": "Enter your current password",
|
"Enter your current password": "Introduce tu contraseña actual",
|
||||||
"enter your full name": "enter your full name",
|
"enter your full name": "introduzca su nombre completo",
|
||||||
"Enter your new password": "Enter your new password",
|
"Enter your new password": "Ingrese su nueva contraseña",
|
||||||
"Enter your new preferred email": "Enter your new preferred email",
|
"Enter your new preferred email": "Introduce tu nuevo correo electrónico preferido",
|
||||||
"Enter your password": "Enter your password",
|
"Enter your password": "Introduce tu contraseña",
|
||||||
"Error fetching page data.": "Error fetching page data.",
|
"Error fetching page data.": "Error al obtener los datos de la página.",
|
||||||
"Error loading page history.": "Error loading page history.",
|
"Error loading page history.": "Error al cargar el historial de la página.",
|
||||||
"Export": "Export",
|
"Export": "Exportar",
|
||||||
"Failed to create page": "Failed to create page",
|
"Failed to create page": "No se pudo crear la página",
|
||||||
"Failed to delete page": "Failed to delete page",
|
"Failed to delete page": "No se pudo eliminar la página",
|
||||||
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
"Failed to fetch recent pages": "Error al obtener las páginas recientes",
|
||||||
"Failed to import pages": "Failed to import pages",
|
"Failed to import pages": "No se pudieron importar las páginas",
|
||||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
"Failed to load page. An error occurred.": "Error al cargar la página. Se produjo un error.",
|
||||||
"Failed to update data": "Failed to update data",
|
"Failed to update data": "No se pudo actualizar los datos",
|
||||||
"Full access": "Full access",
|
"Full access": "Acceso completo",
|
||||||
"Full page width": "Full page width",
|
"Full page width": "Ancho de página completa",
|
||||||
"Full width": "Full width",
|
"Full width": "Ancho completo",
|
||||||
"General": "General",
|
"General": "General",
|
||||||
"Group": "Group",
|
"Group": "Grupo",
|
||||||
"Group description": "Group description",
|
"Group description": "Descripción del grupo",
|
||||||
"Group name": "Group name",
|
"Group name": "Nombre del grupo",
|
||||||
"Groups": "Groups",
|
"Groups": "Grupos",
|
||||||
"Has full access to space settings and pages.": "Has full access to space settings and pages.",
|
"Has full access to space settings and pages.": "Tiene acceso completo a la configuración y páginas del espacio.",
|
||||||
"Home": "Home",
|
"Home": "Inicio",
|
||||||
"Import pages": "Import pages",
|
"Import pages": "Importar páginas",
|
||||||
"Import pages & space settings": "Import pages & space settings",
|
"Import pages & space settings": "Importar páginas y configuraciones del espacio",
|
||||||
"Importing pages": "Importing pages",
|
"Importing pages": "Importando páginas",
|
||||||
"invalid invitation link": "invalid invitation link",
|
"invalid invitation link": "enlace de invitación no válido",
|
||||||
"Invitation signup": "Invitation signup",
|
"Invitation signup": "Registro por invitación",
|
||||||
"Invite by email": "Invite by email",
|
"Invite by email": "Invitar por correo electrónico",
|
||||||
"Invite members": "Invite members",
|
"Invite members": "Invitar a miembros",
|
||||||
"Invite new members": "Invite new members",
|
"Invite new members": "Invitar a nuevos miembros",
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.",
|
"Invited members who are yet to accept their invitation will appear here.": "Los miembros invitados que aún no han aceptado su invitación aparecerán aquí.",
|
||||||
"Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
|
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
|
||||||
"Join the workspace": "Join the workspace",
|
"Join the workspace": "Unirse al espacio de trabajo",
|
||||||
"Language": "Language",
|
"Language": "Idioma",
|
||||||
"Light": "Light",
|
"Light": "Ligero",
|
||||||
"Link copied": "Link copied",
|
"Link copied": "Enlace copiado",
|
||||||
"Login": "Login",
|
"Login": "Iniciar sesión",
|
||||||
"Logout": "Logout",
|
"Logout": "Cerrar sesión",
|
||||||
"Manage Group": "Manage Group",
|
"Manage Group": "Gestionar Grupo",
|
||||||
"Manage members": "Manage members",
|
"Manage members": "Gestionar miembros",
|
||||||
"member": "member",
|
"member": "miembro",
|
||||||
"Member": "Member",
|
"Member": "Miembro",
|
||||||
"members": "members",
|
"members": "miembros",
|
||||||
"Members": "Members",
|
"Members": "Miembros",
|
||||||
"My preferences": "My preferences",
|
"My preferences": "Mis preferencias",
|
||||||
"My Profile": "My Profile",
|
"My Profile": "Mi Perfil",
|
||||||
"My profile": "My profile",
|
"My profile": "Mi perfil",
|
||||||
"Name": "Name",
|
"Name": "Nombre",
|
||||||
"New email": "New email",
|
"New email": "Nuevo correo electrónico",
|
||||||
"New page": "New page",
|
"New page": "Nueva página",
|
||||||
"New password": "New password",
|
"New password": "Nueva contraseña",
|
||||||
"No group found": "No group found",
|
"No group found": "No se encontró grupo",
|
||||||
"No page history saved yet.": "No page history saved yet.",
|
"No page history saved yet.": "No hay historial de la página guardado aún.",
|
||||||
"No pages yet": "No pages yet",
|
"No pages yet": "No hay páginas todavía",
|
||||||
"No results found...": "No results found...",
|
"No results found...": "No se encontraron resultados...",
|
||||||
"No user found": "No user found",
|
"No user found": "No se encontró usuario",
|
||||||
"Overview": "Overview",
|
"Overview": "Visión general",
|
||||||
"Owner": "Owner",
|
"Owner": "Propietario",
|
||||||
"page": "page",
|
"page": "página",
|
||||||
"Page deleted successfully": "Page deleted successfully",
|
"Page deleted successfully": "Página eliminada con éxito",
|
||||||
"Page history": "Page history",
|
"Page history": "Historial de la página",
|
||||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
|
||||||
"Pages": "Pages",
|
"Pages": "Páginas",
|
||||||
"pages": "pages",
|
"pages": "páginas",
|
||||||
"Password": "Password",
|
"Password": "Contraseña",
|
||||||
"Password changed successfully": "Password changed successfully",
|
"Password changed successfully": "Contraseña cambiada con éxito",
|
||||||
"Pending": "Pending",
|
"Pending": "Pendiente",
|
||||||
"Please confirm your action": "Please confirm your action",
|
"Please confirm your action": "Por favor, confirme su acción",
|
||||||
"Preferences": "Preferences",
|
"Preferences": "Preferencias",
|
||||||
"Print PDF": "Print PDF",
|
"Print PDF": "Imprimir PDF",
|
||||||
"Profile": "Profile",
|
"Profile": "Perfil",
|
||||||
"Recently updated": "Recently updated",
|
"Recently updated": "Recientemente actualizado",
|
||||||
"Remove": "Remove",
|
"Remove": "Eliminar",
|
||||||
"Remove group member": "Remove group member",
|
"Remove group member": "Eliminar miembro del grupo",
|
||||||
"Remove space member": "Remove space member",
|
"Remove space member": "Eliminar miembro del espacio",
|
||||||
"Restore": "Restore",
|
"Restore": "Restaurar",
|
||||||
"Role": "Role",
|
"Role": "Rol",
|
||||||
"Save": "Save",
|
"Save": "Guardar",
|
||||||
"Search": "Search",
|
"Search": "Buscar",
|
||||||
"Search for groups": "Search for groups",
|
"Search for groups": "Buscar grupos",
|
||||||
"Search for users": "Search for users",
|
"Search for users": "Buscar usuarios",
|
||||||
"Search for users and groups": "Search for users and groups",
|
"Search for users and groups": "Buscar usuarios y grupos",
|
||||||
"Search...": "Search...",
|
"Search...": "Buscar...",
|
||||||
"Select language": "Select language",
|
"Select language": "Seleccionar idioma",
|
||||||
"Select role": "Select role",
|
"Select role": "Seleccionar rol",
|
||||||
"Select role to assign to all invited members": "Select role to assign to all invited members",
|
"Select role to assign to all invited members": "Seleccionar rol para asignar a todos los miembros invitados",
|
||||||
"Select theme": "Select theme",
|
"Select theme": "Seleccionar tema",
|
||||||
"Send invitation": "Send invitation",
|
"Send invitation": "Enviar invitación",
|
||||||
"Settings": "Settings",
|
"Settings": "Ajustes",
|
||||||
"Setup workspace": "Setup workspace",
|
"Setup workspace": "Configurar espacio de trabajo",
|
||||||
"Sign In": "Sign In",
|
"Sign In": "Iniciar sesión",
|
||||||
"Sign Up": "Sign Up",
|
"Sign Up": "Registrarse",
|
||||||
"Slug": "Slug",
|
"Slug": "Identificador",
|
||||||
"Space": "Space",
|
"Space": "Espacio",
|
||||||
"Space description": "Space description",
|
"Space description": "Descripción del espacio",
|
||||||
"Space menu": "Space menu",
|
"Space menu": "Menú de espacio",
|
||||||
"Space name": "Space name",
|
"Space name": "Nombre del espacio",
|
||||||
"Space settings": "Space settings",
|
"Space settings": "Configuración del espacio",
|
||||||
"Space slug": "Space slug",
|
"Space slug": "Identificador del espacio",
|
||||||
"Spaces": "Spaces",
|
"Spaces": "Espacios",
|
||||||
"Spaces you belong to": "Spaces you belong to",
|
"Spaces you belong to": "Espacios a los que perteneces",
|
||||||
"No space found": "No space found",
|
"No space found": "No se encontró espacio",
|
||||||
"Search for spaces": "Search for spaces",
|
"Search for spaces": "Buscar espacios",
|
||||||
"Start typing to search...": "Start typing to search...",
|
"Start typing to search...": "Empieza a escribir para buscar...",
|
||||||
"Status": "Status",
|
"Status": "Estado",
|
||||||
"Successfully imported": "Successfully imported",
|
"Successfully imported": "Importado con éxito",
|
||||||
"Successfully restored": "Successfully restored",
|
"Successfully restored": "Restaurado con éxito",
|
||||||
"System settings": "System settings",
|
"System settings": "Configuración del sistema",
|
||||||
"Theme": "Theme",
|
"Theme": "Tema",
|
||||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
"To change your email, you have to enter your password and new email.": "Para cambiar tu correo electrónico, debes ingresar tu contraseña y nuevo correo electrónico.",
|
||||||
"Toggle full page width": "Toggle full page width",
|
"Toggle full page width": "Alternar el ancho de página completa",
|
||||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
"Unable to import pages. Please try again.": "No se pueden importar las páginas. Por favor, inténtelo de nuevo.",
|
||||||
"untitled": "untitled",
|
"untitled": "sin título",
|
||||||
"Untitled": "Untitled",
|
"Untitled": "Sin título",
|
||||||
"Updated successfully": "Updated successfully",
|
"Updated successfully": "Actualizado con éxito",
|
||||||
"User": "User",
|
"User": "Usuario",
|
||||||
"Workspace": "Workspace",
|
"Workspace": "Espacio de trabajo",
|
||||||
"Workspace Name": "Workspace Name",
|
"Workspace Name": "Nombre del espacio de trabajo",
|
||||||
"Workspace settings": "Workspace settings",
|
"Workspace settings": "Configuración del espacio de trabajo",
|
||||||
"You can change your password here.": "You can change your password here.",
|
"You can change your password here.": "Puede cambiar su contraseña aquí.",
|
||||||
"Your Email": "Your Email",
|
"Your Email": "Su correo electrónico",
|
||||||
"Your import is complete.": "Your import is complete.",
|
"Your import is complete.": "Su importación está completa.",
|
||||||
"Your name": "Your name",
|
"Your name": "Tu nombre",
|
||||||
"Your Name": "Your Name",
|
"Your Name": "Tu Nombre",
|
||||||
"Your password": "Your password",
|
"Your password": "Tu contraseña",
|
||||||
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
|
"Your password must be a minimum of 8 characters.": "Su contraseña debe tener un mínimo de 8 caracteres.",
|
||||||
"Sidebar toggle": "Sidebar toggle",
|
"Sidebar toggle": "Alternar barra lateral",
|
||||||
"Comments": "Comments",
|
"Comments": "Comentarios",
|
||||||
"404 page not found": "404 page not found",
|
"404 page not found": "404 página no encontrada",
|
||||||
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
|
"Sorry, we can't find the page you are looking for.": "Lo sentimos, no podemos encontrar la página que buscas.",
|
||||||
"Take me back to homepage": "Take me back to homepage",
|
"Take me back to homepage": "Llévame de vuelta a la página de inicio",
|
||||||
"Forgot password": "Forgot password",
|
"Forgot password": "Olvidó la contraseña",
|
||||||
"Forgot your password?": "Forgot your password?",
|
"Forgot your password?": "¿Olvidó su contraseña?",
|
||||||
"A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.",
|
"A password reset link has been sent to your email. Please check your inbox.": "Se ha enviado un enlace para restablecer la contraseña a tu correo electrónico. Por favor, revisa tu bandeja de entrada.",
|
||||||
"Send reset link": "Send reset link",
|
"Send reset link": "Enviar enlace de restablecimiento",
|
||||||
"Password reset": "Password reset",
|
"Password reset": "Restablecimiento de contraseña",
|
||||||
"Your new password": "Your new password",
|
"Your new password": "Tu nueva contraseña",
|
||||||
"Set password": "Set password",
|
"Set password": "Establecer contraseña",
|
||||||
"Write a comment": "Write a comment",
|
"Write a comment": "Escribir un comentario",
|
||||||
"Reply...": "Reply...",
|
"Reply...": "Responder...",
|
||||||
"Error loading comments.": "Error loading comments.",
|
"Error loading comments.": "Error al cargar comentarios.",
|
||||||
"No comments yet.": "No comments yet.",
|
"No comments yet.": "No hay comentarios todavía.",
|
||||||
"Edit comment": "Edit comment",
|
"Edit comment": "Editar comentario",
|
||||||
"Delete comment": "Delete comment",
|
"Delete comment": "Eliminar comentario",
|
||||||
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
|
||||||
"Comment created successfully": "Comment created successfully",
|
"Comment created successfully": "Comentario creado con éxito",
|
||||||
"Error creating comment": "Error creating comment",
|
"Error creating comment": "Error al crear comentario",
|
||||||
"Comment updated successfully": "Comment updated successfully",
|
"Comment updated successfully": "Comentario actualizado con éxito",
|
||||||
"Failed to update comment": "Failed to update comment",
|
"Failed to update comment": "No se pudo actualizar el comentario",
|
||||||
"Comment deleted successfully": "Comment deleted successfully",
|
"Comment deleted successfully": "Comentario eliminado con éxito",
|
||||||
"Failed to delete comment": "Failed to delete comment",
|
"Failed to delete comment": "No se pudo eliminar el comentario",
|
||||||
"Comment resolved successfully": "Comment resolved successfully",
|
"Comment resolved successfully": "Comentario resuelto con éxito",
|
||||||
"Failed to resolve comment": "Failed to resolve comment",
|
"Failed to resolve comment": "No se pudo resolver el comentario",
|
||||||
"Revoke invitation": "Revoke invitation",
|
"Revoke invitation": "Revocar invitación",
|
||||||
"Revoke": "Revoke",
|
"Revoke": "Revocar",
|
||||||
"Don't": "Don't",
|
"Don't": "No",
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.",
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "¿Está seguro de que desea revocar esta invitación? El usuario no podrá unirse al espacio de trabajo.",
|
||||||
"Resend invitation": "Resend invitation",
|
"Resend invitation": "Reenviar invitación",
|
||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Cualquiera con este enlace puede unirse a este espacio de trabajo.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Enlace de invitación",
|
||||||
"Copy": "Copy",
|
"Copy": "Copiar",
|
||||||
"Copied": "Copied",
|
"Copied": "Copiado",
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Seleccionar un usuario",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Seleccionar un grupo",
|
||||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
"Export all pages and attachments in this space.": "Exportar todas las páginas y archivos adjuntos en este espacio.",
|
||||||
"Delete space": "Delete space",
|
"Delete space": "Eliminar espacio",
|
||||||
"Are you sure you want to delete this space?": "Are you sure you want to delete this space?",
|
"Are you sure you want to delete this space?": "¿Está seguro de que desea eliminar este espacio?",
|
||||||
"Delete this space with all its pages and data.": "Delete this space with all its pages and data.",
|
"Delete this space with all its pages and data.": "Eliminar este espacio con todas sus páginas y datos.",
|
||||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "All pages, comments, attachments and permissions in this space will be deleted irreversibly.",
|
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Todas las páginas, comentarios, archivos adjuntos y permisos en este espacio se eliminarán de forma irreversible.",
|
||||||
"Confirm space name": "Confirm space name",
|
"Confirm space name": "Confirmar nombre del espacio",
|
||||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Type the space name <b>{{spaceName}}</b> to confirm your action.",
|
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Escribe el nombre del espacio <b>{{spaceName}}</b> para confirmar tu acción.",
|
||||||
"Format": "Format",
|
"Format": "Formato",
|
||||||
"Include subpages": "Include subpages",
|
"Include subpages": "Incluir subpáginas",
|
||||||
"Include attachments": "Include attachments",
|
"Include attachments": "Incluir adjuntos",
|
||||||
"Select export format": "Select export format",
|
"Select export format": "Seleccionar formato de exportación",
|
||||||
"Export failed:": "Export failed:",
|
"Export failed:": "Exportación fallida:",
|
||||||
"export error": "export error",
|
"export error": "error de exportación",
|
||||||
"Export page": "Export page",
|
"Export page": "Exportar página",
|
||||||
"Export space": "Export space",
|
"Export space": "Exportar espacio",
|
||||||
"Export {{type}}": "Export {{type}}",
|
"Export {{type}}": "Exportar {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||||
"Align left": "Align left",
|
"Align left": "Alinear a la izquierda",
|
||||||
"Align right": "Align right",
|
"Align right": "Alinear a la derecha",
|
||||||
"Align center": "Align center",
|
"Align center": "Alinear al centro",
|
||||||
"Merge cells": "Merge cells",
|
"Merge cells": "Combinar celdas",
|
||||||
"Split cell": "Split cell",
|
"Split cell": "Dividir celda",
|
||||||
"Delete column": "Delete column",
|
"Delete column": "Eliminar columna",
|
||||||
"Delete row": "Delete row",
|
"Delete row": "Eliminar fila",
|
||||||
"Add left column": "Add left column",
|
"Add left column": "Agregar columna izquierda",
|
||||||
"Add right column": "Add right column",
|
"Add right column": "Agregar columna derecha",
|
||||||
"Add row above": "Add row above",
|
"Add row above": "Agregar fila arriba",
|
||||||
"Add row below": "Add row below",
|
"Add row below": "Agregar fila debajo",
|
||||||
"Delete table": "Delete table",
|
"Delete table": "Eliminar tabla",
|
||||||
"Info": "Info",
|
"Info": "Información",
|
||||||
"Success": "Success",
|
"Success": "Satisfactorio",
|
||||||
"Warning": "Warning",
|
"Warning": "Advertencia",
|
||||||
"Danger": "Danger",
|
"Danger": "Peligro",
|
||||||
"Mermaid diagram error:": "Mermaid diagram error:",
|
"Mermaid diagram error:": "Error en diagrama de Mermaid:",
|
||||||
"Invalid Mermaid diagram": "Invalid Mermaid diagram",
|
"Invalid Mermaid diagram": "Diagrama de Mermaid no válido",
|
||||||
"Double-click to edit Draw.io diagram": "Double-click to edit Draw.io diagram",
|
"Double-click to edit Draw.io diagram": "Doble clic para editar el diagrama de Draw.io",
|
||||||
"Exit": "Exit",
|
"Exit": "Salir",
|
||||||
"Save & Exit": "Save & Exit",
|
"Save & Exit": "Guardar y Salir",
|
||||||
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
|
"Double-click to edit Excalidraw diagram": "Doble clic para editar el diagrama de Excalidraw",
|
||||||
"Paste link": "Paste link",
|
"Paste link": "Pegar enlace",
|
||||||
"Edit link": "Edit link",
|
"Edit link": "Editar enlace",
|
||||||
"Remove link": "Remove link",
|
"Remove link": "Eliminar enlace",
|
||||||
"Add link": "Add link",
|
"Add link": "Agregar enlace",
|
||||||
"Please enter a valid url": "Please enter a valid url",
|
"Please enter a valid url": "Por favor, ingrese una URL válida",
|
||||||
"Empty equation": "Empty equation",
|
"Empty equation": "Ecuación vacía",
|
||||||
"Invalid equation": "Invalid equation",
|
"Invalid equation": "Ecuación no válida",
|
||||||
"Color": "Color",
|
"Color": "Color",
|
||||||
"Text color": "Text color",
|
"Text color": "Color del texto",
|
||||||
"Default": "Default",
|
"Default": "Predeterminado",
|
||||||
"Blue": "Blue",
|
"Blue": "Azul",
|
||||||
"Green": "Green",
|
"Green": "Verde",
|
||||||
"Purple": "Purple",
|
"Purple": "Morado",
|
||||||
"Red": "Red",
|
"Red": "Rojo",
|
||||||
"Yellow": "Yellow",
|
"Yellow": "Amarillo",
|
||||||
"Orange": "Orange",
|
"Orange": "Naranja",
|
||||||
"Pink": "Pink",
|
"Pink": "Rosa",
|
||||||
"Gray": "Gray",
|
"Gray": "Gris",
|
||||||
"Embed link": "Embed link",
|
"Embed link": "Enlace adjunto",
|
||||||
"Invalid {{provider}} embed link": "Invalid {{provider}} embed link",
|
"Invalid {{provider}} embed link": "Enlace incrustado {{provider}} no válido",
|
||||||
"Embed {{provider}}": "Embed {{provider}}",
|
"Embed {{provider}}": "Incrustar {{provider}}",
|
||||||
"Enter {{provider}} link to embed": "Enter {{provider}} link to embed",
|
"Enter {{provider}} link to embed": "Introduzca el enlace de {{provider}} para incrustar",
|
||||||
"Bold": "Bold",
|
"Bold": "Negrita",
|
||||||
"Italic": "Italic",
|
"Italic": "Cursiva",
|
||||||
"Underline": "Underline",
|
"Underline": "Subrayar",
|
||||||
"Strike": "Strike",
|
"Strike": "Tachar",
|
||||||
"Code": "Code",
|
"Code": "Código",
|
||||||
"Comment": "Comment",
|
"Comment": "Comentario",
|
||||||
"Text": "Text",
|
"Text": "Texto",
|
||||||
"Heading 1": "Heading 1",
|
"Heading 1": "Encabezado 1",
|
||||||
"Heading 2": "Heading 2",
|
"Heading 2": "Encabezado 2",
|
||||||
"Heading 3": "Heading 3",
|
"Heading 3": "Encabezado 3",
|
||||||
"To-do List": "To-do List",
|
"To-do List": "Lista de cosas por hacer",
|
||||||
"Bullet List": "Bullet List",
|
"Bullet List": "Lista con viñetas",
|
||||||
"Numbered List": "Numbered List",
|
"Numbered List": "Lista numerada",
|
||||||
"Blockquote": "Blockquote",
|
"Blockquote": "Cita en bloque",
|
||||||
"Just start typing with plain text.": "Just start typing with plain text.",
|
"Just start typing with plain text.": "Simplemente comienza a escribir con texto sin formato.",
|
||||||
"Track tasks with a to-do list.": "Track tasks with a to-do list.",
|
"Track tasks with a to-do list.": "Administra tareas con una lista de tareas pendientes.",
|
||||||
"Big section heading.": "Big section heading.",
|
"Big section heading.": "Gran encabezado de sección.",
|
||||||
"Medium section heading.": "Medium section heading.",
|
"Medium section heading.": "Encabezado de sección mediano.",
|
||||||
"Small section heading.": "Small section heading.",
|
"Small section heading.": "Pequeño encabezado de sección.",
|
||||||
"Create a simple bullet list.": "Create a simple bullet list.",
|
"Create a simple bullet list.": "Crear una lista con viñetas simple.",
|
||||||
"Create a list with numbering.": "Create a list with numbering.",
|
"Create a list with numbering.": "Crear una lista con numeración.",
|
||||||
"Create block quote.": "Create block quote.",
|
"Create block quote.": "Crear una cita en bloque.",
|
||||||
"Insert code snippet.": "Insert code snippet.",
|
"Insert code snippet.": "Insertar fragmento de código.",
|
||||||
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
"Insert horizontal rule divider": "Insertar regla horizontal",
|
||||||
"Upload any image from your device.": "Upload any image from your device.",
|
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
||||||
"Upload any video from your device.": "Upload any video from your device.",
|
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
||||||
"Upload any file from your device.": "Upload any file from your device.",
|
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
||||||
"Table": "Table",
|
"Table": "Tabla",
|
||||||
"Insert a table.": "Insert a table.",
|
"Insert a table.": "Insertar una tabla.",
|
||||||
"Insert collapsible block.": "Insert collapsible block.",
|
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||||
"Video": "Video",
|
"Video": "Vídeo",
|
||||||
"Divider": "Divider",
|
"Divider": "Divisor",
|
||||||
"Quote": "Quote",
|
"Quote": "Cita",
|
||||||
"Image": "Image",
|
"Image": "Imagen",
|
||||||
"File attachment": "File attachment",
|
"File attachment": "Adjunto de archivo",
|
||||||
"Toggle block": "Toggle block",
|
"Toggle block": "Alternar bloque",
|
||||||
"Callout": "Callout",
|
"Callout": "Aviso",
|
||||||
"Insert callout notice.": "Insert callout notice.",
|
"Insert callout notice.": "Insertar aviso de llamada.",
|
||||||
"Math inline": "Math inline",
|
"Math inline": "Matemáticas en línea",
|
||||||
"Insert inline math equation.": "Insert inline math equation.",
|
"Insert inline math equation.": "Insertar ecuación matemática en línea.",
|
||||||
"Math block": "Math block",
|
"Math block": "Bloque de matemáticas",
|
||||||
"Insert math equation": "Insert math equation",
|
"Insert math equation": "Insertar ecuación matemática",
|
||||||
"Mermaid diagram": "Mermaid diagram",
|
"Mermaid diagram": "Diagrama de Mermaid",
|
||||||
"Insert mermaid diagram": "Insert mermaid diagram",
|
"Insert mermaid diagram": "Insertar diagrama de Mermaid",
|
||||||
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
"Insert and design Drawio diagrams": "Insertar y diseñar diagramas Drawio",
|
||||||
"Insert current date": "Insert current date",
|
"Insert current date": "Insertar fecha actual",
|
||||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Múltiple",
|
||||||
"Heading {{level}}": "Heading {{level}}",
|
"Heading {{level}}": "Encabezado {{level}}",
|
||||||
"Toggle title": "Toggle title",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||||
"Names do not match": "Names do not match",
|
"Names do not match": "Los nombres no coinciden",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"Today, {{time}}": "Hoy, {{time}}",
|
||||||
"Yesterday, {{time}}": "Yesterday, {{time}}"
|
"Yesterday, {{time}}": "Ayer, {{time}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
{
|
||||||
|
"Account": "Account",
|
||||||
|
"Active": "Attivo",
|
||||||
|
"Add": "Aggiungi",
|
||||||
|
"Add group members": "Aggiungi membri al gruppo",
|
||||||
|
"Add groups": "Aggiungi gruppi",
|
||||||
|
"Add members": "Aggiungi membri",
|
||||||
|
"Add to groups": "Aggiungi ai gruppi",
|
||||||
|
"Add space members": "Aggiungi membri allo spazio",
|
||||||
|
"Admin": "Amministratore",
|
||||||
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Sei sicuro di voler eliminare questo gruppo? I membri perderanno l'accesso alle risorse accessibili da questo gruppo.",
|
||||||
|
"Are you sure you want to delete this page?": "Sei sicuro di voler eliminare questa pagina?",
|
||||||
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sei sicuro di voler rimuovere questo utente dal gruppo? L'utente perderà l'accesso alle risorse accessibili da questo gruppo.",
|
||||||
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sei sicuro di voler rimuovere questo utente dallo spazio? L'utente perderà tutti gli accessi a questo spazio.",
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non salvata come versione andrà persa.",
|
||||||
|
"Can become members of groups and spaces in workspace": "Può diventare membro di gruppi e spazi nell'area di lavoro",
|
||||||
|
"Can create and edit pages in space.": "Può creare e modificare le pagine nello spazio.",
|
||||||
|
"Can edit": "Può modificare",
|
||||||
|
"Can manage workspace": "Può gestire lo spazio di lavoro",
|
||||||
|
"Can manage workspace but cannot delete it": "Può gestire lo spazio di lavoro ma non può eliminarlo",
|
||||||
|
"Can view": "Può visualizzare",
|
||||||
|
"Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non modificarle.",
|
||||||
|
"Cancel": "Annulla",
|
||||||
|
"Change email": "Cambia email",
|
||||||
|
"Change password": "Cambia password",
|
||||||
|
"Change photo": "Cambia foto",
|
||||||
|
"Choose a role": "Scegli un ruolo",
|
||||||
|
"Choose your preferred color scheme.": "Scegli il tuo schema di colori preferito.",
|
||||||
|
"Choose your preferred interface language.": "Scegli la tua lingua preferita per l'interfaccia.",
|
||||||
|
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||||
|
"Confirm": "Conferma",
|
||||||
|
"Copy link": "Copia link",
|
||||||
|
"Create": "Crea",
|
||||||
|
"Create group": "Crea gruppo",
|
||||||
|
"Create page": "Crea pagina",
|
||||||
|
"Create space": "Crea spazio",
|
||||||
|
"Create workspace": "Crea spazio di lavoro",
|
||||||
|
"Current password": "Password attuale",
|
||||||
|
"Dark": "Scuro",
|
||||||
|
"Date": "Data",
|
||||||
|
"Delete": "Elimina",
|
||||||
|
"Delete group": "Elimina gruppo",
|
||||||
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
|
||||||
|
"Description": "Descrizione",
|
||||||
|
"Details": "Dettagli",
|
||||||
|
"e.g ACME": "ad es. ACME",
|
||||||
|
"e.g ACME Inc": "es. ACME Inc",
|
||||||
|
"e.g Developers": "es. Sviluppatori",
|
||||||
|
"e.g Group for developers": "es. Gruppo per gli sviluppatori",
|
||||||
|
"e.g product": "ad esempio prodotto",
|
||||||
|
"e.g Product Team": "es. Team di Prodotto",
|
||||||
|
"e.g Sales": "ad es. Vendite",
|
||||||
|
"e.g Space for product team": "ad es. Spazio per il team di prodotto",
|
||||||
|
"e.g Space for sales team to collaborate": "ad es. Spazio per il team di vendita per collaborare",
|
||||||
|
"Edit": "Modifica",
|
||||||
|
"Edit group": "Modifica gruppo",
|
||||||
|
"Email": "Email",
|
||||||
|
"Enter a strong password": "Inserisci una password sicura",
|
||||||
|
"Enter valid email addresses separated by comma or space max_50": "Inserisci indirizzi email validi separati da virgola o spazio [max: 50]",
|
||||||
|
"enter valid emails addresses": "inserisci indirizzi email validi",
|
||||||
|
"Enter your current password": "Inserisci la tua password attuale",
|
||||||
|
"enter your full name": "inserisci il tuo nome completo",
|
||||||
|
"Enter your new password": "Inserisci la tua nuova password",
|
||||||
|
"Enter your new preferred email": "Inserisci la tua nuova email preferita",
|
||||||
|
"Enter your password": "Inserisci la tua password",
|
||||||
|
"Error fetching page data.": "Si è verificato un errore durante il recupero dei dati della pagina.",
|
||||||
|
"Error loading page history.": "Si è verificato un errore durante il caricamento della cronologia della pagina.",
|
||||||
|
"Export": "Esporta",
|
||||||
|
"Failed to create page": "Impossibile creare pagina",
|
||||||
|
"Failed to delete page": "Impossibile eliminare la pagina",
|
||||||
|
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
|
||||||
|
"Failed to import pages": "Impossibile importare le pagine",
|
||||||
|
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
|
||||||
|
"Failed to update data": "Impossibile aggiornare i dati",
|
||||||
|
"Full access": "Accesso completo",
|
||||||
|
"Full page width": "Larghezza pagina intera",
|
||||||
|
"Full width": "Larghezza intera",
|
||||||
|
"General": "Generale",
|
||||||
|
"Group": "Gruppo",
|
||||||
|
"Group description": "Descrizione del gruppo",
|
||||||
|
"Group name": "Nome del gruppo",
|
||||||
|
"Groups": "Gruppi",
|
||||||
|
"Has full access to space settings and pages.": "Ha pieno accesso alle impostazioni e alle pagine dello spazio.",
|
||||||
|
"Home": "Casa",
|
||||||
|
"Import pages": "Importa pagine",
|
||||||
|
"Import pages & space settings": "Importa pagine e impostazioni dello spazio",
|
||||||
|
"Importing pages": "Importazione pagine",
|
||||||
|
"invalid invitation link": "link di invito non valido",
|
||||||
|
"Invitation signup": "Iscrizione invito",
|
||||||
|
"Invite by email": "Invita via email",
|
||||||
|
"Invite members": "Invita membri",
|
||||||
|
"Invite new members": "Invita nuovi membri",
|
||||||
|
"Invited members who are yet to accept their invitation will appear here.": "I membri invitati che non hanno ancora accettato il loro invito appariranno qui.",
|
||||||
|
"Invited members will be granted access to spaces the groups can access": "I membri invitati avranno accesso agli spazi a cui i gruppi possono accedere",
|
||||||
|
"Join the workspace": "Unisciti allo spazio di lavoro",
|
||||||
|
"Language": "Lingua",
|
||||||
|
"Light": "Chiaro",
|
||||||
|
"Link copied": "Link copiato",
|
||||||
|
"Login": "Login",
|
||||||
|
"Logout": "Esci",
|
||||||
|
"Manage Group": "Gestisci Gruppo",
|
||||||
|
"Manage members": "Gestisci membri",
|
||||||
|
"member": "membro",
|
||||||
|
"Member": "Membro",
|
||||||
|
"members": "membri",
|
||||||
|
"Members": "Membri",
|
||||||
|
"My preferences": "Le mie preferenze",
|
||||||
|
"My Profile": "Il mio profilo",
|
||||||
|
"My profile": "Il mio profilo",
|
||||||
|
"Name": "Nome",
|
||||||
|
"New email": "Nuova email",
|
||||||
|
"New page": "Nuova pagina",
|
||||||
|
"New password": "Nuova password",
|
||||||
|
"No group found": "Nessun gruppo trovato",
|
||||||
|
"No page history saved yet.": "Nessuna cronologia della pagina salvata.",
|
||||||
|
"No pages yet": "Nessuna pagina ancora",
|
||||||
|
"No results found...": "Nessun risultato trovato...",
|
||||||
|
"No user found": "Nessun utente trovato",
|
||||||
|
"Overview": "Panoramica",
|
||||||
|
"Owner": "Proprietario",
|
||||||
|
"page": "pagina",
|
||||||
|
"Page deleted successfully": "Pagina eliminata con successo",
|
||||||
|
"Page history": "Cronologia della pagina",
|
||||||
|
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
|
||||||
|
"Pages": "Pagine",
|
||||||
|
"pages": "pagine",
|
||||||
|
"Password": "Password",
|
||||||
|
"Password changed successfully": "Password cambiata con successo",
|
||||||
|
"Pending": "In sospeso",
|
||||||
|
"Please confirm your action": "Si prega di confermare la propria azione",
|
||||||
|
"Preferences": "Preferenze",
|
||||||
|
"Print PDF": "Stampa PDF",
|
||||||
|
"Profile": "Profilo",
|
||||||
|
"Recently updated": "Aggiornato di recente",
|
||||||
|
"Remove": "Rimuovi",
|
||||||
|
"Remove group member": "Rimuovi membro dal gruppo",
|
||||||
|
"Remove space member": "Rimuovi membro dallo spazio",
|
||||||
|
"Restore": "Ripristina",
|
||||||
|
"Role": "Ruolo",
|
||||||
|
"Save": "Salva",
|
||||||
|
"Search": "Cerca",
|
||||||
|
"Search for groups": "Cerca gruppi",
|
||||||
|
"Search for users": "Cerca un utente",
|
||||||
|
"Search for users and groups": "Cerca utenti e gruppi",
|
||||||
|
"Search...": "Cerca...",
|
||||||
|
"Select language": "Seleziona lingua",
|
||||||
|
"Select role": "Seleziona ruolo",
|
||||||
|
"Select role to assign to all invited members": "Seleziona il ruolo da assegnare a tutti i membri invitati",
|
||||||
|
"Select theme": "Seleziona tema",
|
||||||
|
"Send invitation": "Invia invito",
|
||||||
|
"Settings": "Impostazioni",
|
||||||
|
"Setup workspace": "Imposta spazio di lavoro",
|
||||||
|
"Sign In": "Accedi",
|
||||||
|
"Sign Up": "Registrati",
|
||||||
|
"Slug": "Identificatore",
|
||||||
|
"Space": "Spazio",
|
||||||
|
"Space description": "Descrizione dello spazio",
|
||||||
|
"Space menu": "Menu spazio",
|
||||||
|
"Space name": "Nome dello spazio",
|
||||||
|
"Space settings": "Impostazioni dello spazio",
|
||||||
|
"Space slug": "Lumaca spaziale",
|
||||||
|
"Spaces": "Spazi",
|
||||||
|
"Spaces you belong to": "Spazi a cui appartieni",
|
||||||
|
"No space found": "Nessuno spazio trovato",
|
||||||
|
"Search for spaces": "Cerca spazi",
|
||||||
|
"Start typing to search...": "Inizia a digitare per cercare...",
|
||||||
|
"Status": "Stato",
|
||||||
|
"Successfully imported": "Importazione riuscita",
|
||||||
|
"Successfully restored": "Ripristinato con successo",
|
||||||
|
"System settings": "Impostazioni di sistema",
|
||||||
|
"Theme": "Tema",
|
||||||
|
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
|
||||||
|
"Toggle full page width": "Attiva/disattiva larghezza pagina intera",
|
||||||
|
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
|
||||||
|
"untitled": "senza titolo",
|
||||||
|
"Untitled": "Senza titolo",
|
||||||
|
"Updated successfully": "Aggiornato con successo",
|
||||||
|
"User": "Utente",
|
||||||
|
"Workspace": "Spazio di lavoro",
|
||||||
|
"Workspace Name": "Nome dello spazio di lavoro",
|
||||||
|
"Workspace settings": "Impostazioni dello spazio di lavoro",
|
||||||
|
"You can change your password here.": "Puoi cambiare la tua password qui.",
|
||||||
|
"Your Email": "La tua email",
|
||||||
|
"Your import is complete.": "Il tuo importazione è completata.",
|
||||||
|
"Your name": "Il tuo nome",
|
||||||
|
"Your Name": "Il Tuo Nome",
|
||||||
|
"Your password": "La tua password",
|
||||||
|
"Your password must be a minimum of 8 characters.": "La tua password deve contenere almeno 8 caratteri.",
|
||||||
|
"Sidebar toggle": "Attiva/disattiva barra laterale",
|
||||||
|
"Comments": "Commenti",
|
||||||
|
"404 page not found": "404 pagina non trovata",
|
||||||
|
"Sorry, we can't find the page you are looking for.": "Siamo spiacenti, non riusciamo a trovare la pagina che stai cercando.",
|
||||||
|
"Take me back to homepage": "Portami alla homepage",
|
||||||
|
"Forgot password": "Hai dimenticato la password",
|
||||||
|
"Forgot your password?": "Hai dimenticato la password?",
|
||||||
|
"A password reset link has been sent to your email. Please check your inbox.": "Un link per il reset della password è stato inviato al tuo indirizzo email. Per favore, controlla la tua casella di posta.",
|
||||||
|
"Send reset link": "Invia link di ripristino",
|
||||||
|
"Password reset": "Reimposta password",
|
||||||
|
"Your new password": "La tua nuova password",
|
||||||
|
"Set password": "Imposta password",
|
||||||
|
"Write a comment": "Scrivi un commento",
|
||||||
|
"Reply...": "Rispondi...",
|
||||||
|
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
|
||||||
|
"No comments yet.": "Nessun commento ancora.",
|
||||||
|
"Edit comment": "Modifica commento",
|
||||||
|
"Delete comment": "Elimina commento",
|
||||||
|
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
|
||||||
|
"Comment created successfully": "Commento creato con successo",
|
||||||
|
"Error creating comment": "Si è verificato un errore durante la creazione del commento",
|
||||||
|
"Comment updated successfully": "Commento aggiornato con successo",
|
||||||
|
"Failed to update comment": "Impossibile aggiornare il commento",
|
||||||
|
"Comment deleted successfully": "Commento eliminato con successo",
|
||||||
|
"Failed to delete comment": "Impossibile eliminare il commento",
|
||||||
|
"Comment resolved successfully": "Commento risolto con successo",
|
||||||
|
"Failed to resolve comment": "Impossibile risolvere il commento",
|
||||||
|
"Revoke invitation": "Revoca invito",
|
||||||
|
"Revoke": "Revoca",
|
||||||
|
"Don't": "Non",
|
||||||
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Sei sicuro di voler revocare questo invito? L'utente non potrà unirsi allo spazio di lavoro.",
|
||||||
|
"Resend invitation": "Rispedisci invito",
|
||||||
|
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questo workspace.",
|
||||||
|
"Invite link": "Link d'invito",
|
||||||
|
"Copy": "Copia",
|
||||||
|
"Copied": "Copiato",
|
||||||
|
"Select a user": "Seleziona un utente",
|
||||||
|
"Select a group": "Seleziona un gruppo",
|
||||||
|
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati in questo spazio.",
|
||||||
|
"Delete space": "Elimina spazio",
|
||||||
|
"Are you sure you want to delete this space?": "Sei sicuro di voler eliminare questo spazio?",
|
||||||
|
"Delete this space with all its pages and data.": "Elimina questo spazio con tutte le sue pagine e i suoi dati.",
|
||||||
|
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Tutte le pagine, i commenti, gli allegati e i permessi in questo spazio verranno eliminati in modo irreversibile.",
|
||||||
|
"Confirm space name": "Conferma nome spazio",
|
||||||
|
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Digita il nome dello spazio <b>{{spaceName}}</b> per confermare la tua azione.",
|
||||||
|
"Format": "Formato",
|
||||||
|
"Include subpages": "Includi sottopagine",
|
||||||
|
"Include attachments": "Includi allegati",
|
||||||
|
"Select export format": "Seleziona formato di esportazione",
|
||||||
|
"Export failed:": "Esportazione fallita:",
|
||||||
|
"export error": "errore di esportazione",
|
||||||
|
"Export page": "Esporta pagina",
|
||||||
|
"Export space": "Esporta spazio",
|
||||||
|
"Export {{type}}": "Esporta {{type}}",
|
||||||
|
"File exceeds the {{limit}} attachment limit": "Il file supera il limite di allegati di {{limit}}",
|
||||||
|
"Align left": "Allinea a sinistra",
|
||||||
|
"Align right": "Allinea a destra",
|
||||||
|
"Align center": "Allinea al centro",
|
||||||
|
"Merge cells": "Unisci celle",
|
||||||
|
"Split cell": "Dividi cella",
|
||||||
|
"Delete column": "Elimina colonna",
|
||||||
|
"Delete row": "Elimina riga",
|
||||||
|
"Add left column": "Aggiungi colonna a sinistra",
|
||||||
|
"Add right column": "Aggiungi colonna a destra",
|
||||||
|
"Add row above": "Aggiungi riga sopra",
|
||||||
|
"Add row below": "Aggiungi riga sotto",
|
||||||
|
"Delete table": "Elimina tabella",
|
||||||
|
"Info": "Informazioni",
|
||||||
|
"Success": "Successo",
|
||||||
|
"Warning": "Avviso",
|
||||||
|
"Danger": "Pericolo",
|
||||||
|
"Mermaid diagram error:": "Errore nel diagramma di Mermaid:",
|
||||||
|
"Invalid Mermaid diagram": "Diagramma di Mermaid non valido",
|
||||||
|
"Double-click to edit Draw.io diagram": "Doppio clic per modificare il diagramma Draw.io",
|
||||||
|
"Exit": "Esci",
|
||||||
|
"Save & Exit": "Salva ed esci",
|
||||||
|
"Double-click to edit Excalidraw diagram": "Doppio clic per modificare il diagramma Excalidraw",
|
||||||
|
"Paste link": "Incolla link",
|
||||||
|
"Edit link": "Modifica link",
|
||||||
|
"Remove link": "Rimuovi link",
|
||||||
|
"Add link": "Aggiungi link",
|
||||||
|
"Please enter a valid url": "Per favore inserisci un URL valido",
|
||||||
|
"Empty equation": "Equazione vuota",
|
||||||
|
"Invalid equation": "Equazione non valida",
|
||||||
|
"Color": "Colore",
|
||||||
|
"Text color": "Colore del testo",
|
||||||
|
"Default": "Predefinito",
|
||||||
|
"Blue": "Blu",
|
||||||
|
"Green": "Verde",
|
||||||
|
"Purple": "Viola",
|
||||||
|
"Red": "Rosso",
|
||||||
|
"Yellow": "Giallo",
|
||||||
|
"Orange": "Arancione",
|
||||||
|
"Pink": "Rosa",
|
||||||
|
"Gray": "Grigio",
|
||||||
|
"Embed link": "Incorpora collegamento",
|
||||||
|
"Invalid {{provider}} embed link": "Link di incorporamento {{provider}} non valido",
|
||||||
|
"Embed {{provider}}": "Incorpora {{provider}}",
|
||||||
|
"Enter {{provider}} link to embed": "Inserisci il link {{provider}} per incorporare",
|
||||||
|
"Bold": "Grassetto",
|
||||||
|
"Italic": "Corsivo",
|
||||||
|
"Underline": "Sottolineato",
|
||||||
|
"Strike": "Barrato",
|
||||||
|
"Code": "Codice",
|
||||||
|
"Comment": "Commento",
|
||||||
|
"Text": "Testo",
|
||||||
|
"Heading 1": "Intestazione 1",
|
||||||
|
"Heading 2": "Intestazione 2",
|
||||||
|
"Heading 3": "Intestazione 3",
|
||||||
|
"To-do List": "Lista delle cose da fare",
|
||||||
|
"Bullet List": "Elenco Puntato",
|
||||||
|
"Numbered List": "Elenco Numerato",
|
||||||
|
"Blockquote": "Blocco di citazione",
|
||||||
|
"Just start typing with plain text.": "Inizia a digitare con testo semplice.",
|
||||||
|
"Track tasks with a to-do list.": "Tieni traccia delle attività con una lista di cose da fare.",
|
||||||
|
"Big section heading.": "Intestazione di una grande sezione.",
|
||||||
|
"Medium section heading.": "Intestazione di sezione media.",
|
||||||
|
"Small section heading.": "Piccolo titolo di sezione.",
|
||||||
|
"Create a simple bullet list.": "Crea un semplice elenco puntato.",
|
||||||
|
"Create a list with numbering.": "Crea un elenco numerato.",
|
||||||
|
"Create block quote.": "Crea blocco citazione.",
|
||||||
|
"Insert code snippet.": "Inserisci frammento di codice.",
|
||||||
|
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
|
||||||
|
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
||||||
|
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
||||||
|
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
|
||||||
|
"Table": "Tabella",
|
||||||
|
"Insert a table.": "Inserisci una tabella.",
|
||||||
|
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
||||||
|
"Video": "Video",
|
||||||
|
"Divider": "Divisore",
|
||||||
|
"Quote": "Preventivo",
|
||||||
|
"Image": "Immagine",
|
||||||
|
"File attachment": "Allegato file",
|
||||||
|
"Toggle block": "Attiva blocco",
|
||||||
|
"Callout": "Avviso",
|
||||||
|
"Insert callout notice.": "Inserisci avviso di richiamo.",
|
||||||
|
"Math inline": "Matematica in linea",
|
||||||
|
"Insert inline math equation.": "Inserisci equazione matematica in linea.",
|
||||||
|
"Math block": "Blocco matematico",
|
||||||
|
"Insert math equation": "Inserisci equazione matematica",
|
||||||
|
"Mermaid diagram": "Diagramma di Mermaid",
|
||||||
|
"Insert mermaid diagram": "Inserisci un diagramma di Mermaid",
|
||||||
|
"Insert and design Drawio diagrams": "Inserisci e progetta diagrammi Drawio",
|
||||||
|
"Insert current date": "Inserisci la data corrente",
|
||||||
|
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
|
||||||
|
"Multiple": "Multiplo",
|
||||||
|
"Heading {{level}}": "Intestazione {{level}}",
|
||||||
|
"Toggle title": "Attiva/disattiva titolo",
|
||||||
|
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
||||||
|
"Names do not match": "I nomi non corrispondono",
|
||||||
|
"Today, {{time}}": "Oggi, {{time}}",
|
||||||
|
"Yesterday, {{time}}": "Ieri, {{time}}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
{
|
||||||
|
"Account": "アカウント",
|
||||||
|
"Active": "アクティブ",
|
||||||
|
"Add": "追加",
|
||||||
|
"Add group members": "グループメンバーを追加",
|
||||||
|
"Add groups": "グループを追加",
|
||||||
|
"Add members": "メンバーを追加",
|
||||||
|
"Add to groups": "グループに追加",
|
||||||
|
"Add space members": "スペースメンバーを追加",
|
||||||
|
"Admin": "管理者",
|
||||||
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "このグループを削除してもよろしいですか? メンバーはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
|
||||||
|
"Are you sure you want to delete this page?": "このページを削除してもよろしいですか?",
|
||||||
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
|
||||||
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
|
||||||
|
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
|
||||||
|
"Can create and edit pages in space.": "スペース内のページを作成および編集できます。",
|
||||||
|
"Can edit": "編集可能",
|
||||||
|
"Can manage workspace": "ワークスペースを管理できます",
|
||||||
|
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが、削除はできません",
|
||||||
|
"Can view": "閲覧可能",
|
||||||
|
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが、編集はできません。",
|
||||||
|
"Cancel": "キャンセル",
|
||||||
|
"Change email": "メールアドレスの変更",
|
||||||
|
"Change password": "パスワードの変更",
|
||||||
|
"Change photo": "画像の変更",
|
||||||
|
"Choose a role": "ロールを選んでください",
|
||||||
|
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください。",
|
||||||
|
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください。",
|
||||||
|
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください。",
|
||||||
|
"Confirm": "確認",
|
||||||
|
"Copy link": "リンクをコピー",
|
||||||
|
"Create": "新規作成",
|
||||||
|
"Create group": "グループを作成",
|
||||||
|
"Create page": "新規ページ",
|
||||||
|
"Create space": "新規スペース",
|
||||||
|
"Create workspace": "ワークスペースを作成",
|
||||||
|
"Current password": "現在のパスワード",
|
||||||
|
"Dark": "ダーク",
|
||||||
|
"Date": "日付",
|
||||||
|
"Delete": "削除",
|
||||||
|
"Delete group": "グループを削除",
|
||||||
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴が削除されます。この操作は元に戻せません。",
|
||||||
|
"Description": "説明",
|
||||||
|
"Details": "詳細",
|
||||||
|
"e.g ACME": "例: 山田太郎",
|
||||||
|
"e.g ACME Inc": "例: 株式会社サンプル",
|
||||||
|
"e.g Developers": "例: エンジニア",
|
||||||
|
"e.g Group for developers": "例: エンジニアグループ",
|
||||||
|
"e.g product": "例: product",
|
||||||
|
"e.g Product Team": "例: 製品チーム",
|
||||||
|
"e.g Sales": "例: 営業",
|
||||||
|
"e.g Space for product team": "例: 製品チームのスペース",
|
||||||
|
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
|
||||||
|
"Edit": "編集",
|
||||||
|
"Edit group": "グループを編集",
|
||||||
|
"Email": "メールアドレス",
|
||||||
|
"Enter a strong password": "強力なパスワードを入力してください",
|
||||||
|
"Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペースで区切って入力してください(最大 50 個)",
|
||||||
|
"enter valid emails addresses": "有効なメールアドレスを入力してください",
|
||||||
|
"Enter your current password": "現在のパスワードを入力してください",
|
||||||
|
"enter your full name": "氏名を入力してください",
|
||||||
|
"Enter your new password": "新しいパスワードを入力してください",
|
||||||
|
"Enter your new preferred email": "新しいメールアドレスを入力してください",
|
||||||
|
"Enter your password": "パスワードを入力してください",
|
||||||
|
"Error fetching page data.": "ページデータ取得中にエラーが発生しました。",
|
||||||
|
"Error loading page history.": "ページ履歴の読み込み中にエラーが発生しました。",
|
||||||
|
"Export": "エクスポート",
|
||||||
|
"Failed to create page": "ページの作成に失敗しました",
|
||||||
|
"Failed to delete page": "ページの削除に失敗しました",
|
||||||
|
"Failed to fetch recent pages": "最近のページを取得できませんでした",
|
||||||
|
"Failed to import pages": "ページのインポートに失敗しました",
|
||||||
|
"Failed to load page. An error occurred.": "ページの読み込みに失敗しました。エラーが発生しました。",
|
||||||
|
"Failed to update data": "データの更新に失敗しました",
|
||||||
|
"Full access": "フルアクセス",
|
||||||
|
"Full page width": "フルページ幅で表示",
|
||||||
|
"Full width": "左右の余白を縮小",
|
||||||
|
"General": "一般",
|
||||||
|
"Group": "グループ",
|
||||||
|
"Group description": "グループ説明",
|
||||||
|
"Group name": "グループ名",
|
||||||
|
"Groups": "グループ",
|
||||||
|
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます。",
|
||||||
|
"Home": "ホーム",
|
||||||
|
"Import pages": "ページをインポート",
|
||||||
|
"Import pages & space settings": "ページとスペース設定をインポート",
|
||||||
|
"Importing pages": "ページをインポートしています",
|
||||||
|
"invalid invitation link": "招待リンクが間違っています",
|
||||||
|
"Invitation signup": "招待登録",
|
||||||
|
"Invite by email": "メールアドレスで招待する",
|
||||||
|
"Invite members": "メンバーを招待する",
|
||||||
|
"Invite new members": "新しいメンバーを招待する",
|
||||||
|
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーはここに表示されます。",
|
||||||
|
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーは、グループがアクセスできるスペースにアクセス権が付与されます",
|
||||||
|
"Join the workspace": "ワークスペースに参加",
|
||||||
|
"Language": "言語",
|
||||||
|
"Light": "ライト",
|
||||||
|
"Link copied": "リンクをコピーしました",
|
||||||
|
"Login": "ログイン",
|
||||||
|
"Logout": "ログアウト",
|
||||||
|
"Manage Group": "グループを管理",
|
||||||
|
"Manage members": "メンバーを管理",
|
||||||
|
"member": "メンバー",
|
||||||
|
"Member": "メンバー",
|
||||||
|
"members": "メンバー",
|
||||||
|
"Members": "メンバー",
|
||||||
|
"My preferences": "個人設定",
|
||||||
|
"My Profile": "プロフィール",
|
||||||
|
"My profile": "プロフィール",
|
||||||
|
"Name": "名前",
|
||||||
|
"New email": "新しいメールアドレス",
|
||||||
|
"New page": "新規ページ",
|
||||||
|
"New password": "新しいパスワード",
|
||||||
|
"No group found": "グループが見つかりません",
|
||||||
|
"No page history saved yet.": "まだページの履歴が保存されていません。",
|
||||||
|
"No pages yet": "ページがありません",
|
||||||
|
"No results found...": "結果が見つかりませんでした...",
|
||||||
|
"No user found": "ユーザがいません",
|
||||||
|
"Overview": "概要",
|
||||||
|
"Owner": "所有者",
|
||||||
|
"page": "ページ",
|
||||||
|
"Page deleted successfully": "ページが正常に削除されました",
|
||||||
|
"Page history": "ページの履歴",
|
||||||
|
"Page import is in progress. Please do not close this tab.": "ページのインポートが進行中です。このタブを閉じないでください。",
|
||||||
|
"Pages": "ページ",
|
||||||
|
"pages": "ページ",
|
||||||
|
"Password": "パスワード",
|
||||||
|
"Password changed successfully": "パスワードが正常に変更されました",
|
||||||
|
"Pending": "保留中",
|
||||||
|
"Please confirm your action": "アクションを確認してください",
|
||||||
|
"Preferences": "設定",
|
||||||
|
"Print PDF": "PDFを印刷",
|
||||||
|
"Profile": "プロフィール",
|
||||||
|
"Recently updated": "最近の更新",
|
||||||
|
"Remove": "削除",
|
||||||
|
"Remove group member": "グループメンバーを削除",
|
||||||
|
"Remove space member": "スペースメンバーを削除",
|
||||||
|
"Restore": "復元",
|
||||||
|
"Role": "役割",
|
||||||
|
"Save": "保存",
|
||||||
|
"Search": "検索",
|
||||||
|
"Search for groups": "グループを検索",
|
||||||
|
"Search for users": "ユーザーを検索",
|
||||||
|
"Search for users and groups": "ユーザーとグループを検索",
|
||||||
|
"Search...": "検索する語句を入力",
|
||||||
|
"Select language": "言語を選択",
|
||||||
|
"Select role": "ロールを選択",
|
||||||
|
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
|
||||||
|
"Select theme": "テーマを選択",
|
||||||
|
"Send invitation": "招待を送る",
|
||||||
|
"Settings": "設定",
|
||||||
|
"Setup workspace": "ワークスペースを設定する",
|
||||||
|
"Sign In": "サインイン",
|
||||||
|
"Sign Up": "アカウント登録",
|
||||||
|
"Slug": "Slug (URL用文字列)",
|
||||||
|
"Space": "スペース",
|
||||||
|
"Space description": "スペース説明",
|
||||||
|
"Space menu": "スペースメニュー",
|
||||||
|
"Space name": "スペース名",
|
||||||
|
"Space settings": "スペース設定",
|
||||||
|
"Space slug": "スペースのSlug (URL用文字列)",
|
||||||
|
"Spaces": "スペース",
|
||||||
|
"Spaces you belong to": "所属しているスペース",
|
||||||
|
"No space found": "スペースが見つかりません",
|
||||||
|
"Search for spaces": "スペースを検索",
|
||||||
|
"Start typing to search...": "検索を開始するには入力してください...",
|
||||||
|
"Status": "ステータス",
|
||||||
|
"Successfully imported": "インポートに成功しました",
|
||||||
|
"Successfully restored": "正常に復元されました",
|
||||||
|
"System settings": "システム設定",
|
||||||
|
"Theme": "テーマ",
|
||||||
|
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
|
||||||
|
"Toggle full page width": "ページ幅を切り替える",
|
||||||
|
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください。",
|
||||||
|
"untitled": "無題",
|
||||||
|
"Untitled": "無題",
|
||||||
|
"Updated successfully": "正常に更新されました",
|
||||||
|
"User": "ユーザー",
|
||||||
|
"Workspace": "ワークスペース",
|
||||||
|
"Workspace Name": "ワークスペース名",
|
||||||
|
"Workspace settings": "ワークスペース設定",
|
||||||
|
"You can change your password here.": "パスワードを変更できます。",
|
||||||
|
"Your Email": "メールアドレス",
|
||||||
|
"Your import is complete.": "インポートが完了しました。",
|
||||||
|
"Your name": "名前",
|
||||||
|
"Your Name": "名前",
|
||||||
|
"Your password": "パスワード",
|
||||||
|
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
|
||||||
|
"Sidebar toggle": "サイドバー切り替え",
|
||||||
|
"Comments": "コメント",
|
||||||
|
"404 page not found": "404 ページが見つかりません",
|
||||||
|
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません。",
|
||||||
|
"Take me back to homepage": "ホームに戻る",
|
||||||
|
"Forgot password": "パスワードを忘れた",
|
||||||
|
"Forgot your password?": "パスワードを忘れましたか?",
|
||||||
|
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信箱を確認してください。",
|
||||||
|
"Send reset link": "リセットリンクを送る",
|
||||||
|
"Password reset": "パスワードリセット",
|
||||||
|
"Your new password": "新しいパスワード",
|
||||||
|
"Set password": "パスワードを設定",
|
||||||
|
"Write a comment": "コメントを書く",
|
||||||
|
"Reply...": "返信...",
|
||||||
|
"Error loading comments.": "コメントの読み込み中にエラーが発生しました。",
|
||||||
|
"No comments yet.": "コメントがありません。",
|
||||||
|
"Edit comment": "コメントを編集する",
|
||||||
|
"Delete comment": "コメントを削除する",
|
||||||
|
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
|
||||||
|
"Comment created successfully": "コメントが作成されました",
|
||||||
|
"Error creating comment": "コメントの作成中にエラーが発生しました",
|
||||||
|
"Comment updated successfully": "コメントが更新されました",
|
||||||
|
"Failed to update comment": "コメントの更新に失敗しました",
|
||||||
|
"Comment deleted successfully": "コメントが削除されました",
|
||||||
|
"Failed to delete comment": "コメントの削除に失敗しました",
|
||||||
|
"Comment resolved successfully": "コメントが解決されました",
|
||||||
|
"Failed to resolve comment": "コメントの解決に失敗しました",
|
||||||
|
"Revoke invitation": "招待を取り消す",
|
||||||
|
"Revoke": "取り消す",
|
||||||
|
"Don't": "取り消さない",
|
||||||
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります。",
|
||||||
|
"Resend invitation": "招待を再度送る",
|
||||||
|
"Anyone with this link can join this workspace.": "このリンクを持っている人は誰でもこのワークスペースに参加できます。",
|
||||||
|
"Invite link": "招待リンク",
|
||||||
|
"Copy": "コピー",
|
||||||
|
"Copied": "コピーしました",
|
||||||
|
"Select a user": "ユーザを選択",
|
||||||
|
"Select a group": "グループを選択",
|
||||||
|
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
|
||||||
|
"Delete space": "スペースを削除",
|
||||||
|
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
|
||||||
|
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページとデータを削除します。",
|
||||||
|
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限は完全に削除されます。",
|
||||||
|
"Confirm space name": "スペース名を確認する",
|
||||||
|
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためにスペース名 <b>{{spaceName}}</b> を入力してください。",
|
||||||
|
"Format": "フォーマット",
|
||||||
|
"Include subpages": "サブページを含める",
|
||||||
|
"Include attachments": "添付ファイルを含める",
|
||||||
|
"Select export format": "エクスポート形式を選択",
|
||||||
|
"Export failed:": "エクスポートに失敗しました:",
|
||||||
|
"export error": "エクスポートエラー",
|
||||||
|
"Export page": "エクスポートページ",
|
||||||
|
"Export space": "エクスポートスペース",
|
||||||
|
"Export {{type}}": "{{type}}をエクスポート",
|
||||||
|
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||||
|
"Align left": "左揃え",
|
||||||
|
"Align right": "右揃え",
|
||||||
|
"Align center": "中央揃え",
|
||||||
|
"Merge cells": "セルを結合",
|
||||||
|
"Split cell": "セルを分割",
|
||||||
|
"Delete column": "列を削除",
|
||||||
|
"Delete row": "行を削除",
|
||||||
|
"Add left column": "左側に列を追加",
|
||||||
|
"Add right column": "右側の列を追加",
|
||||||
|
"Add row above": "上に行を追加",
|
||||||
|
"Add row below": "下に行を追加",
|
||||||
|
"Delete table": "テーブルを削除",
|
||||||
|
"Info": "情報",
|
||||||
|
"Success": "成功",
|
||||||
|
"Warning": "警告",
|
||||||
|
"Danger": "危険",
|
||||||
|
"Mermaid diagram error:": "Mermaid コードエラー",
|
||||||
|
"Invalid Mermaid diagram": "無効な Mermaid コードです",
|
||||||
|
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.ioの図を編集",
|
||||||
|
"Exit": "終了",
|
||||||
|
"Save & Exit": "保存して終了",
|
||||||
|
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
|
||||||
|
"Paste link": "リンクを貼り付け",
|
||||||
|
"Edit link": "リンクを編集",
|
||||||
|
"Remove link": "リンクを削除",
|
||||||
|
"Add link": "リンクを追加",
|
||||||
|
"Please enter a valid url": "有効なURLを入力してください",
|
||||||
|
"Empty equation": "空の数式です",
|
||||||
|
"Invalid equation": "不正な数式です",
|
||||||
|
"Color": "カラー",
|
||||||
|
"Text color": "テキストカラー",
|
||||||
|
"Default": "デフォルト",
|
||||||
|
"Blue": "青色",
|
||||||
|
"Green": "緑色",
|
||||||
|
"Purple": "紫色",
|
||||||
|
"Red": "赤色",
|
||||||
|
"Yellow": "黄色",
|
||||||
|
"Orange": "オレンジ色",
|
||||||
|
"Pink": "ピンク色",
|
||||||
|
"Gray": "灰色",
|
||||||
|
"Embed link": "リンクを埋め込む",
|
||||||
|
"Invalid {{provider}} embed link": "埋め込まれた {{provider}} のリンクは無効です",
|
||||||
|
"Embed {{provider}}": "埋め込まれた {{provider}}",
|
||||||
|
"Enter {{provider}} link to embed": "埋め込みたい {{provider}} のリンクを入力してください",
|
||||||
|
"Bold": "太字",
|
||||||
|
"Italic": "斜線",
|
||||||
|
"Underline": "下線",
|
||||||
|
"Strike": "打ち消し線",
|
||||||
|
"Code": "コードブロック",
|
||||||
|
"Comment": "コメント",
|
||||||
|
"Text": "テキスト",
|
||||||
|
"Heading 1": "見出し 1",
|
||||||
|
"Heading 2": "見出し 2",
|
||||||
|
"Heading 3": "見出し 3",
|
||||||
|
"To-do List": "To-doリスト",
|
||||||
|
"Bullet List": "箇条書きリスト",
|
||||||
|
"Numbered List": "番号付きリスト",
|
||||||
|
"Blockquote": "引用",
|
||||||
|
"Just start typing with plain text.": "すぐに文章を書き始められます。",
|
||||||
|
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します。",
|
||||||
|
"Big section heading.": "大きいフォントのセクション見出しです。",
|
||||||
|
"Medium section heading.": "中くらいのフォントのセクション見出しです。",
|
||||||
|
"Small section heading.": "小さいフォントのセクション見出しです。",
|
||||||
|
"Create a simple bullet list.": "シンプルな箇条書きのリストを作成します。",
|
||||||
|
"Create a list with numbering.": "番号付きのリストを作成します。",
|
||||||
|
"Create block quote.": "引用文を作成します。",
|
||||||
|
"Insert code snippet.": "コードスニペットを入力します。",
|
||||||
|
"Insert horizontal rule divider": "水平線を挿入します。",
|
||||||
|
"Upload any image from your device.": "画像をアップロードします。",
|
||||||
|
"Upload any video from your device.": "動画をアップロードします。",
|
||||||
|
"Upload any file from your device.": "ファイルをアップロードします。",
|
||||||
|
"Table": "テーブル",
|
||||||
|
"Insert a table.": "表を挿入します。",
|
||||||
|
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します。",
|
||||||
|
"Video": "動画",
|
||||||
|
"Divider": "区切り線",
|
||||||
|
"Quote": "引用",
|
||||||
|
"Image": "画像",
|
||||||
|
"File attachment": "ファイル添付",
|
||||||
|
"Toggle block": "ブロックを切り替える",
|
||||||
|
"Callout": "コールアウト",
|
||||||
|
"Insert callout notice.": "コールアウトブロックを挿入します。",
|
||||||
|
"Math inline": "インライン数式",
|
||||||
|
"Insert inline math equation.": "インライン数式を挿入します。",
|
||||||
|
"Math block": "数式ブロック",
|
||||||
|
"Insert math equation": "数式を挿入します",
|
||||||
|
"Mermaid diagram": "Mermaidコード",
|
||||||
|
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
|
||||||
|
"Insert and design Drawio diagrams": "Drawioの図を挿入してデザインします",
|
||||||
|
"Insert current date": "今日の日付を挿入します",
|
||||||
|
"Draw and sketch excalidraw diagrams": "Excalidrawの図を埋め込みます",
|
||||||
|
"Multiple": "複数",
|
||||||
|
"Heading {{level}}": "見出し {{level}}",
|
||||||
|
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||||
|
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
||||||
|
"Names do not match": "名前が一致しません",
|
||||||
|
"Today, {{time}}": "今日、{{time}}",
|
||||||
|
"Yesterday, {{time}}": "昨日、{{time}}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
{
|
||||||
|
"Account": "계정",
|
||||||
|
"Active": "활성",
|
||||||
|
"Add": "추가",
|
||||||
|
"Add group members": "팀에 사용자 추가",
|
||||||
|
"Add groups": "팀 생성",
|
||||||
|
"Add members": "사용자 추가",
|
||||||
|
"Add to groups": "팀에 추가",
|
||||||
|
"Add space members": "Space에 사용자 추가",
|
||||||
|
"Admin": "관리자",
|
||||||
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 팀을 삭제하시겠습니까? 해당 팀에 속한 사용자들은 이 팀이 가진 모든 권한을 잃게 됩니다.",
|
||||||
|
"Are you sure you want to delete this page?": "이 페이지를 삭제하시겠습니까?",
|
||||||
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 팀에서 제거하시겠습니까? 사용자는 이 팀이 가진 모든 권한을 잃게 됩니다.",
|
||||||
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.",
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.",
|
||||||
|
"Can become members of groups and spaces in workspace": "Workspace 내 팀 및 Space의 사용자가 될 수 있습니다.",
|
||||||
|
"Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.",
|
||||||
|
"Can edit": "편집할 수 있음",
|
||||||
|
"Can manage workspace": "Workspace를 관리할 수 있음",
|
||||||
|
"Can manage workspace but cannot delete it": "Workspace를 관리할 수 있지만, 삭제는 불가능.",
|
||||||
|
"Can view": "볼 수 있음",
|
||||||
|
"Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.",
|
||||||
|
"Cancel": "취소",
|
||||||
|
"Change email": "이메일 변경",
|
||||||
|
"Change password": "비밀번호 변경",
|
||||||
|
"Change photo": "사진 변경",
|
||||||
|
"Choose a role": "역할 선택",
|
||||||
|
"Choose your preferred color scheme.": "선호하는 배경 색을 선택하세요.",
|
||||||
|
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
||||||
|
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
||||||
|
"Confirm": "확인",
|
||||||
|
"Copy link": "링크 복사",
|
||||||
|
"Create": "생성",
|
||||||
|
"Create group": "팀 생성",
|
||||||
|
"Create page": "페이지 생성",
|
||||||
|
"Create space": "Space 생성",
|
||||||
|
"Create workspace": "Workspace 생성",
|
||||||
|
"Current password": "현재 비밀번호",
|
||||||
|
"Dark": "어두운",
|
||||||
|
"Date": "날짜",
|
||||||
|
"Delete": "삭제",
|
||||||
|
"Delete group": "팀 삭제",
|
||||||
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"Description": "설명",
|
||||||
|
"Details": "세부사항",
|
||||||
|
"e.g ACME": "예: ACME",
|
||||||
|
"e.g ACME Inc": "예: ACME Inc",
|
||||||
|
"e.g Developers": "예: 개발자",
|
||||||
|
"e.g Group for developers": "예: 개발자를 위한 팀",
|
||||||
|
"e.g product": "예: 제품",
|
||||||
|
"e.g Product Team": "예: 제품 팀",
|
||||||
|
"e.g Sales": "예: 영업",
|
||||||
|
"e.g Space for product team": "예: 제품 팀을 위한 Space",
|
||||||
|
"e.g Space for sales team to collaborate": "예: 영업 팀의 Space",
|
||||||
|
"Edit": "편집",
|
||||||
|
"Edit group": "팀 편집",
|
||||||
|
"Email": "이메일",
|
||||||
|
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||||
|
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||||
|
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||||
|
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||||
|
"enter your full name": "전체 이름을 입력하세요",
|
||||||
|
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||||
|
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||||
|
"Enter your password": "비밀번호를 입력하세요",
|
||||||
|
"Error fetching page data.": "페이지 데이터 불러오기 오류.",
|
||||||
|
"Error loading page history.": "페이지 기록 불러오기 오류.",
|
||||||
|
"Export": "내보내기",
|
||||||
|
"Failed to create page": "페이지 생성 실패",
|
||||||
|
"Failed to delete page": "페이지 삭제 실패",
|
||||||
|
"Failed to fetch recent pages": "최근 페이지 불러오기 실패",
|
||||||
|
"Failed to import pages": "페이지 가져오기 실패",
|
||||||
|
"Failed to load page. An error occurred.": "페이지 불러오기 실패. 오류가 발생했습니다.",
|
||||||
|
"Failed to update data": "데이터 갱신 실패",
|
||||||
|
"Full access": "전체 권한",
|
||||||
|
"Full page width": "전체 페이지 너비",
|
||||||
|
"Full width": "전체 너비",
|
||||||
|
"General": "일반",
|
||||||
|
"Group": "팀",
|
||||||
|
"Group description": "팀 설명",
|
||||||
|
"Group name": "팀 이름",
|
||||||
|
"Groups": "팀",
|
||||||
|
"Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
|
||||||
|
"Home": "홈",
|
||||||
|
"Import pages": "페이지 가져오기",
|
||||||
|
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
|
||||||
|
"Importing pages": "페이지 가져오기 중",
|
||||||
|
"invalid invitation link": "유효하지 않은 초대 링크",
|
||||||
|
"Invitation signup": "초대 가입",
|
||||||
|
"Invite by email": "이메일로 초대",
|
||||||
|
"Invite members": "사용자 초대",
|
||||||
|
"Invite new members": "새 사용자 초대",
|
||||||
|
"Invited members who are yet to accept their invitation will appear here.": "초대를 아직 수락하지 않은 초대된 사용자가 여기에 표시됩니다.",
|
||||||
|
"Invited members will be granted access to spaces the groups can access": "초대된 사용자는 팀이 접근할 수 있는 Space에 대한 접근 권한을 받게 됩니다",
|
||||||
|
"Join the workspace": "Workspace 참여",
|
||||||
|
"Language": "언어",
|
||||||
|
"Light": "밝은",
|
||||||
|
"Link copied": "링크 복사됨",
|
||||||
|
"Login": "로그인",
|
||||||
|
"Logout": "로그아웃",
|
||||||
|
"Manage Group": "팀 관리",
|
||||||
|
"Manage members": "사용자 관리",
|
||||||
|
"member": "사용자",
|
||||||
|
"Member": "사용자",
|
||||||
|
"members": "사용자들",
|
||||||
|
"Members": "사용자들",
|
||||||
|
"My preferences": "내 설정",
|
||||||
|
"My Profile": "내 프로필",
|
||||||
|
"My profile": "내 프로필",
|
||||||
|
"Name": "이름",
|
||||||
|
"New email": "새 이메일",
|
||||||
|
"New page": "새 페이지",
|
||||||
|
"New password": "새 비밀번호",
|
||||||
|
"No group found": "팀을 찾을 수 없음",
|
||||||
|
"No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.",
|
||||||
|
"No pages yet": "아직 페이지가 없습니다",
|
||||||
|
"No results found...": "결과를 찾을 수 없습니다...",
|
||||||
|
"No user found": "사용자를 찾을 수 없음",
|
||||||
|
"Overview": "개요",
|
||||||
|
"Owner": "소유자",
|
||||||
|
"page": "페이지",
|
||||||
|
"Page deleted successfully": "페이지 삭제 완료",
|
||||||
|
"Page history": "페이지 기록",
|
||||||
|
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
|
||||||
|
"Pages": "페이지",
|
||||||
|
"pages": "페이지",
|
||||||
|
"Password": "비밀번호",
|
||||||
|
"Password changed successfully": "비밀번호 변경 완료",
|
||||||
|
"Pending": "대기 중",
|
||||||
|
"Please confirm your action": "작업을 확인해 주세요",
|
||||||
|
"Preferences": "설정",
|
||||||
|
"Print PDF": "PDF로 인쇄",
|
||||||
|
"Profile": "프로필",
|
||||||
|
"Recently updated": "최근 업데이트",
|
||||||
|
"Remove": "제거",
|
||||||
|
"Remove group member": "팀에서 사용자 제거",
|
||||||
|
"Remove space member": "Space에서 사용자 제거",
|
||||||
|
"Restore": "복원",
|
||||||
|
"Role": "역할",
|
||||||
|
"Save": "저장",
|
||||||
|
"Search": "검색",
|
||||||
|
"Search for groups": "팀 검색",
|
||||||
|
"Search for users": "사용자 검색",
|
||||||
|
"Search for users and groups": "사용자 및 팀 검색",
|
||||||
|
"Search...": "검색...",
|
||||||
|
"Select language": "언어 선택",
|
||||||
|
"Select role": "역할 선택",
|
||||||
|
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",
|
||||||
|
"Select theme": "배경 선택",
|
||||||
|
"Send invitation": "초대 보내기",
|
||||||
|
"Settings": "설정",
|
||||||
|
"Setup workspace": "Workspace 설정",
|
||||||
|
"Sign In": "로그인",
|
||||||
|
"Sign Up": "회원 가입",
|
||||||
|
"Slug": "고유 경로",
|
||||||
|
"Space": "Space",
|
||||||
|
"Space description": "Space 설명",
|
||||||
|
"Space menu": "Space 메뉴",
|
||||||
|
"Space name": "Space 이름",
|
||||||
|
"Space settings": "Space 설정",
|
||||||
|
"Space slug": "Space의 고유 경로",
|
||||||
|
"Spaces": "Space",
|
||||||
|
"Spaces you belong to": "소속된 Space",
|
||||||
|
"No space found": "Space을 찾을 수 없음",
|
||||||
|
"Search for spaces": "Space 검색",
|
||||||
|
"Start typing to search...": "검색하려면 입력을 시작하세요...",
|
||||||
|
"Status": "상태",
|
||||||
|
"Successfully imported": "가져오기 완료",
|
||||||
|
"Successfully restored": "복원 완료",
|
||||||
|
"System settings": "시스템 설정",
|
||||||
|
"Theme": "배경",
|
||||||
|
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||||
|
"Toggle full page width": "전체 페이지 너비 전환",
|
||||||
|
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||||
|
"untitled": "제목 없음",
|
||||||
|
"Untitled": "제목 없음",
|
||||||
|
"Updated successfully": "업데이트 완료",
|
||||||
|
"User": "사용자",
|
||||||
|
"Workspace": "Workspace",
|
||||||
|
"Workspace Name": "Workspce 이름",
|
||||||
|
"Workspace settings": "Workspace 설정",
|
||||||
|
"You can change your password here.": "여기서 비밀번호를 변경할 수 있습니다.",
|
||||||
|
"Your Email": "이메일",
|
||||||
|
"Your import is complete.": "가져오기가 완료되었습니다.",
|
||||||
|
"Your name": "이름",
|
||||||
|
"Your Name": "이름",
|
||||||
|
"Your password": "비밀번호",
|
||||||
|
"Your password must be a minimum of 8 characters.": "비밀번호는 최소 8자 이상이어야 합니다.",
|
||||||
|
"Sidebar toggle": "사이드바 전환",
|
||||||
|
"Comments": "댓글",
|
||||||
|
"404 page not found": "404 페이지를 찾을 수 없음",
|
||||||
|
"Sorry, we can't find the page you are looking for.": "죄송합니다. 페이지를 찾을 수 없습니다.",
|
||||||
|
"Take me back to homepage": "홈페이지로 돌아가기",
|
||||||
|
"Forgot password": "비밀번호 찾기",
|
||||||
|
"Forgot your password?": "비밀번호를 잊으셨나요?",
|
||||||
|
"A password reset link has been sent to your email. Please check your inbox.": "비밀번호 재설정 링크가 이메일로 전송되었습니다. 받은 편지함을 확인해주세요.",
|
||||||
|
"Send reset link": "재설정 링크 보내기",
|
||||||
|
"Password reset": "비밀번호 재설정",
|
||||||
|
"Your new password": "새 비밀번호",
|
||||||
|
"Set password": "비밀번호 설정",
|
||||||
|
"Write a comment": "댓글 작성",
|
||||||
|
"Reply...": "답글...",
|
||||||
|
"Error loading comments.": "댓글 불러오기 오류.",
|
||||||
|
"No comments yet.": "아직 댓글이 없습니다.",
|
||||||
|
"Edit comment": "댓글 수정",
|
||||||
|
"Delete comment": "댓글 삭제",
|
||||||
|
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
|
||||||
|
"Comment created successfully": "댓글 생성 완료",
|
||||||
|
"Error creating comment": "댓글 생성 오류",
|
||||||
|
"Comment updated successfully": "댓글 업데이트 완료",
|
||||||
|
"Failed to update comment": "댓글 업데이트 실패",
|
||||||
|
"Comment deleted successfully": "댓글 삭제 완료",
|
||||||
|
"Failed to delete comment": "댓글 삭제 실패",
|
||||||
|
"Comment resolved successfully": "댓글 처리 완료",
|
||||||
|
"Failed to resolve comment": "댓글 처리 실패",
|
||||||
|
"Revoke invitation": "초대 취소",
|
||||||
|
"Revoke": "취소",
|
||||||
|
"Don't": "하지 않음",
|
||||||
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
|
||||||
|
"Resend invitation": "초대 재전송",
|
||||||
|
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사람이 Workspace에 참여할 수 있습니다.",
|
||||||
|
"Invite link": "초대 링크",
|
||||||
|
"Copy": "복사",
|
||||||
|
"Copied": "복사됨",
|
||||||
|
"Select a user": "사용자 선택",
|
||||||
|
"Select a group": "팀 선택",
|
||||||
|
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
|
||||||
|
"Delete space": "Space 삭제",
|
||||||
|
"Are you sure you want to delete this space?": "이 Space을 삭제하시겠습니까?",
|
||||||
|
"Delete this space with all its pages and data.": "이 Space의 모든 페이지와 데이터를 삭제합니다.",
|
||||||
|
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "이 Space의 모든 페이지, 댓글, 첨부파일 및 권한이 영구적으로 삭제됩니다.",
|
||||||
|
"Confirm space name": "Space 이름 확인",
|
||||||
|
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "작업을 진행하려면 Space 이름 <b>{{spaceName}}</b>을 입력하세요.",
|
||||||
|
"Format": "형식",
|
||||||
|
"Include subpages": "하위 페이지 포함",
|
||||||
|
"Include attachments": "첨부파일 포함",
|
||||||
|
"Select export format": "내보내기 형식 선택",
|
||||||
|
"Export failed:": "내보내기 실패:",
|
||||||
|
"export error": "내보내기 오류",
|
||||||
|
"Export page": "페이지 내보내기",
|
||||||
|
"Export space": "Space 내보내기",
|
||||||
|
"Export {{type}}": "{{type}} 내보내기",
|
||||||
|
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
||||||
|
"Align left": "왼쪽 정렬",
|
||||||
|
"Align right": "오른쪽 정렬",
|
||||||
|
"Align center": "가운데 정렬",
|
||||||
|
"Merge cells": "셀 병합",
|
||||||
|
"Split cell": "셀 분할",
|
||||||
|
"Delete column": "열 삭제",
|
||||||
|
"Delete row": "행 삭제",
|
||||||
|
"Add left column": "왼쪽 열 추가",
|
||||||
|
"Add right column": "오른쪽 열 추가",
|
||||||
|
"Add row above": "위에 행 추가",
|
||||||
|
"Add row below": "아래에 행 추가",
|
||||||
|
"Delete table": "테이블 삭제",
|
||||||
|
"Info": "정보",
|
||||||
|
"Success": "완료",
|
||||||
|
"Warning": "경고",
|
||||||
|
"Danger": "위험",
|
||||||
|
"Mermaid diagram error:": "Mermaid diagram 오류:",
|
||||||
|
"Invalid Mermaid diagram": "잘못된 Mermaid diagram",
|
||||||
|
"Double-click to edit Draw.io diagram": "Draw.io diagram을 편집하려면 더블 클릭하세요",
|
||||||
|
"Exit": "나가기",
|
||||||
|
"Save & Exit": "저장 후 나가기",
|
||||||
|
"Double-click to edit Excalidraw diagram": "Excalidraw diagram을 편집하려면 더블 클릭하세요",
|
||||||
|
"Paste link": "링크 붙여넣기",
|
||||||
|
"Edit link": "링크 편집",
|
||||||
|
"Remove link": "링크 제거",
|
||||||
|
"Add link": "링크 추가",
|
||||||
|
"Please enter a valid url": "유효한 URL을 입력하세요",
|
||||||
|
"Empty equation": "빈 수식",
|
||||||
|
"Invalid equation": "잘못된 수식",
|
||||||
|
"Color": "색상",
|
||||||
|
"Text color": "텍스트 색상",
|
||||||
|
"Default": "기본값",
|
||||||
|
"Blue": "파란색",
|
||||||
|
"Green": "초록색",
|
||||||
|
"Purple": "보라색",
|
||||||
|
"Red": "빨간색",
|
||||||
|
"Yellow": "노란색",
|
||||||
|
"Orange": "주황색",
|
||||||
|
"Pink": "분홍색",
|
||||||
|
"Gray": "회색",
|
||||||
|
"Embed link": "임베드 링크",
|
||||||
|
"Invalid {{provider}} embed link": "잘못된 {{provider}} 임베드 링크",
|
||||||
|
"Embed {{provider}}": "{{provider}} 임베드",
|
||||||
|
"Enter {{provider}} link to embed": "임베드를 할 {{provider}} 링크 입력",
|
||||||
|
"Bold": "굵게",
|
||||||
|
"Italic": "기울임",
|
||||||
|
"Underline": "밑줄",
|
||||||
|
"Strike": "취소선",
|
||||||
|
"Code": "코드",
|
||||||
|
"Comment": "댓글",
|
||||||
|
"Text": "텍스트",
|
||||||
|
"Heading 1": "제목 1",
|
||||||
|
"Heading 2": "제목 2",
|
||||||
|
"Heading 3": "제목 3",
|
||||||
|
"To-do List": "할 일 목록",
|
||||||
|
"Bullet List": "글머리 기호 목록",
|
||||||
|
"Numbered List": "번호 매기기 목록",
|
||||||
|
"Blockquote": "인용구",
|
||||||
|
"Just start typing with plain text.": "일반 텍스트로 입력을 시작하세요.",
|
||||||
|
"Track tasks with a to-do list.": "할 일 목록으로 작업을 추적하세요.",
|
||||||
|
"Big section heading.": "대제목.",
|
||||||
|
"Medium section heading.": "중제목.",
|
||||||
|
"Small section heading.": "소제목.",
|
||||||
|
"Create a simple bullet list.": "글머리 기호 만들기.",
|
||||||
|
"Create a list with numbering.": "숫자 목록 만들기.",
|
||||||
|
"Create block quote.": "인용구 만들기.",
|
||||||
|
"Insert code snippet.": "코드 블록 삽입.",
|
||||||
|
"Insert horizontal rule divider": "가로 구분선 삽입",
|
||||||
|
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
||||||
|
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
||||||
|
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
||||||
|
"Table": "테이블",
|
||||||
|
"Insert a table.": "테이블 삽입.",
|
||||||
|
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
||||||
|
"Video": "비디오",
|
||||||
|
"Divider": "구분선",
|
||||||
|
"Quote": "인용",
|
||||||
|
"Image": "이미지",
|
||||||
|
"File attachment": "파일 첨부",
|
||||||
|
"Toggle block": "블록 토글",
|
||||||
|
"Callout": "경고 상자",
|
||||||
|
"Insert callout notice.": "돋보이는 글을 작성하기.",
|
||||||
|
"Math inline": "수식",
|
||||||
|
"Insert inline math equation.": "수식 삽입.",
|
||||||
|
"Math block": "수식 블록",
|
||||||
|
"Insert math equation": "수식 삽입",
|
||||||
|
"Mermaid diagram": "Mermaid diagram",
|
||||||
|
"Insert mermaid diagram": "Mermaid diagram 삽입",
|
||||||
|
"Insert and design Drawio diagrams": "Drawio diagram 삽입 및 디자인",
|
||||||
|
"Insert current date": "현재 날짜 삽입",
|
||||||
|
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
|
||||||
|
"Multiple": "복제",
|
||||||
|
"Heading {{level}}": "제목 {{level}}",
|
||||||
|
"Toggle title": "제목 토글",
|
||||||
|
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
||||||
|
"Names do not match": "이름이 일치하지 않습니다",
|
||||||
|
"Today, {{time}}": "오늘, {{time}}",
|
||||||
|
"Yesterday, {{time}}": "어제, {{time}}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
{
|
||||||
|
"Account": "Аккаунт",
|
||||||
|
"Active": "Активный",
|
||||||
|
"Add": "Добавить",
|
||||||
|
"Add group members": "Добавить участников группы",
|
||||||
|
"Add groups": "Добавить группы",
|
||||||
|
"Add members": "Добавить участников",
|
||||||
|
"Add to groups": "Добавить в группы",
|
||||||
|
"Add space members": "Добавить участников пространства",
|
||||||
|
"Admin": "Администратор",
|
||||||
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
||||||
|
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
||||||
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
|
||||||
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||||
|
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
|
||||||
|
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||||
|
"Can edit": "Может изменять",
|
||||||
|
"Can manage workspace": "Может управлять рабочим пространством",
|
||||||
|
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
|
||||||
|
"Can view": "Может просматривать",
|
||||||
|
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
||||||
|
"Cancel": "Отменить",
|
||||||
|
"Change email": "Изменить электронную почту",
|
||||||
|
"Change password": "Изменить пароль",
|
||||||
|
"Change photo": "Изменить фото",
|
||||||
|
"Choose a role": "Выберите роль",
|
||||||
|
"Choose your preferred color scheme.": "Выберите предпочитаемую цветовую схему.",
|
||||||
|
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
||||||
|
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
||||||
|
"Confirm": "Подтвердить",
|
||||||
|
"Copy link": "Копировать ссылку",
|
||||||
|
"Create": "Создать",
|
||||||
|
"Create group": "Создать группу",
|
||||||
|
"Create page": "Создать страницу",
|
||||||
|
"Create space": "Создать пространство",
|
||||||
|
"Create workspace": "Создать рабочее пространство",
|
||||||
|
"Current password": "Текущий пароль",
|
||||||
|
"Dark": "Темная",
|
||||||
|
"Date": "Дата",
|
||||||
|
"Delete": "Удалить",
|
||||||
|
"Delete group": "Удалить группу",
|
||||||
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
|
||||||
|
"Description": "Описание",
|
||||||
|
"Details": "Подробности",
|
||||||
|
"e.g ACME": "например, ACME",
|
||||||
|
"e.g ACME Inc": "например, ACME Inc",
|
||||||
|
"e.g Developers": "например, Разработчики",
|
||||||
|
"e.g Group for developers": "например, Группа для разработчиков",
|
||||||
|
"e.g product": "например, продукт",
|
||||||
|
"e.g Product Team": "например, Продуктовая команда",
|
||||||
|
"e.g Sales": "например, Продажи",
|
||||||
|
"e.g Space for product team": "например, Пространство для продуктовой команды",
|
||||||
|
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
|
||||||
|
"Edit": "Редактировать",
|
||||||
|
"Edit group": "Редактировать группу",
|
||||||
|
"Email": "Электронная почта",
|
||||||
|
"Enter a strong password": "Введите надёжный пароль",
|
||||||
|
"Enter valid email addresses separated by comma or space max_50": "Введите действительные адреса электронной почты, разделенные запятой или пробелом [макс: 50]",
|
||||||
|
"enter valid emails addresses": "введите действительные адреса электронной почты",
|
||||||
|
"Enter your current password": "Введите ваш текущий пароль",
|
||||||
|
"enter your full name": "введите ваше полное имя",
|
||||||
|
"Enter your new password": "Введите ваш новый пароль",
|
||||||
|
"Enter your new preferred email": "Введите ваш новый предпочитаемый адрес электронной почты",
|
||||||
|
"Enter your password": "Введите ваш пароль",
|
||||||
|
"Error fetching page data.": "Ошибка при загрузке данных страницы.",
|
||||||
|
"Error loading page history.": "Ошибка при загрузке истории страницы.",
|
||||||
|
"Export": "Экспорт",
|
||||||
|
"Failed to create page": "Не удалось создать страницу",
|
||||||
|
"Failed to delete page": "Не удалось удалить страницу",
|
||||||
|
"Failed to fetch recent pages": "Не удалось получить недавние страницы",
|
||||||
|
"Failed to import pages": "Не удалось импортировать страницы",
|
||||||
|
"Failed to load page. An error occurred.": "Не удалось загрузить страницу. Произошла ошибка.",
|
||||||
|
"Failed to update data": "Не удалось обновить данные",
|
||||||
|
"Full access": "Полный доступ",
|
||||||
|
"Full page width": "Ширина на всю страницу",
|
||||||
|
"Full width": "Во всю ширину",
|
||||||
|
"General": "Основные",
|
||||||
|
"Group": "Группа",
|
||||||
|
"Group description": "Описание группы",
|
||||||
|
"Group name": "Название группы",
|
||||||
|
"Groups": "Группы",
|
||||||
|
"Has full access to space settings and pages.": "Имеет полный доступ к настройкам пространства и страницам.",
|
||||||
|
"Home": "Главная",
|
||||||
|
"Import pages": "Импортировать страницы",
|
||||||
|
"Import pages & space settings": "Импорт страниц и настройки пространства",
|
||||||
|
"Importing pages": "Импортирование страниц",
|
||||||
|
"invalid invitation link": "ссылка на приглашение недействительна",
|
||||||
|
"Invitation signup": "Регистрация по приглашению",
|
||||||
|
"Invite by email": "Пригласить по электронной почте",
|
||||||
|
"Invite members": "Пригласить участников",
|
||||||
|
"Invite new members": "Пригласить новых участников",
|
||||||
|
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
||||||
|
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
||||||
|
"Join the workspace": "Присоединиться к рабочему пространству",
|
||||||
|
"Language": "Язык",
|
||||||
|
"Light": "Светлая",
|
||||||
|
"Link copied": "Ссылка скопирована",
|
||||||
|
"Login": "Войти",
|
||||||
|
"Logout": "Выйти",
|
||||||
|
"Manage Group": "Управление группой",
|
||||||
|
"Manage members": "Управление участниками",
|
||||||
|
"member": "участник",
|
||||||
|
"Member": "Участник",
|
||||||
|
"members": "участники",
|
||||||
|
"Members": "Участники",
|
||||||
|
"My preferences": "Мои настройки",
|
||||||
|
"My Profile": "Мой Профиль",
|
||||||
|
"My profile": "Мой профиль",
|
||||||
|
"Name": "Имя",
|
||||||
|
"New email": "Новый электронный адрес",
|
||||||
|
"New page": "Новая страница",
|
||||||
|
"New password": "Новый пароль",
|
||||||
|
"No group found": "Группа не найдена",
|
||||||
|
"No page history saved yet.": "История страниц ещё не сохранена.",
|
||||||
|
"No pages yet": "Страниц пока нет",
|
||||||
|
"No results found...": "Результаты не найдены...",
|
||||||
|
"No user found": "Пользователь не найден",
|
||||||
|
"Overview": "Обзор",
|
||||||
|
"Owner": "Владелец",
|
||||||
|
"page": "страница",
|
||||||
|
"Page deleted successfully": "Страница успешно удалена",
|
||||||
|
"Page history": "История страницы",
|
||||||
|
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
||||||
|
"Pages": "Страницы",
|
||||||
|
"pages": "страницы",
|
||||||
|
"Password": "Пароль",
|
||||||
|
"Password changed successfully": "Пароль успешно изменён",
|
||||||
|
"Pending": "В ожидании",
|
||||||
|
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
|
||||||
|
"Preferences": "Внешний вид",
|
||||||
|
"Print PDF": "Печать PDF",
|
||||||
|
"Profile": "Профиль",
|
||||||
|
"Recently updated": "Обновлено недавно",
|
||||||
|
"Remove": "Удалить",
|
||||||
|
"Remove group member": "Удалить участника группы",
|
||||||
|
"Remove space member": "Удалить участника пространства",
|
||||||
|
"Restore": "Восстановить",
|
||||||
|
"Role": "Роль",
|
||||||
|
"Save": "Сохранить",
|
||||||
|
"Search": "Поиск",
|
||||||
|
"Search for groups": "Поиск групп",
|
||||||
|
"Search for users": "Поиск пользователей",
|
||||||
|
"Search for users and groups": "Поиск пользователей и групп",
|
||||||
|
"Search...": "Поиск...",
|
||||||
|
"Select language": "Выберите язык",
|
||||||
|
"Select role": "Выберите роль",
|
||||||
|
"Select role to assign to all invited members": "Выберите роль для всех приглашённых участников",
|
||||||
|
"Select theme": "Выберите тему",
|
||||||
|
"Send invitation": "Отправить приглашение",
|
||||||
|
"Settings": "Настройки",
|
||||||
|
"Setup workspace": "Настроить рабочее пространство",
|
||||||
|
"Sign In": "Вход",
|
||||||
|
"Sign Up": "Регистрация",
|
||||||
|
"Slug": "Slug",
|
||||||
|
"Space": "Пространство",
|
||||||
|
"Space description": "Описание пространства",
|
||||||
|
"Space menu": "Меню пространства",
|
||||||
|
"Space name": "Название пространства",
|
||||||
|
"Space settings": "Настройки пространства",
|
||||||
|
"Space slug": "Slug пространства",
|
||||||
|
"Spaces": "Пространства",
|
||||||
|
"Spaces you belong to": "Пространства, к которым вы принадлежите",
|
||||||
|
"No space found": "Пространства не найдены",
|
||||||
|
"Search for spaces": "Поиск пространств",
|
||||||
|
"Start typing to search...": "Начните вводить для поиска...",
|
||||||
|
"Status": "Статус",
|
||||||
|
"Successfully imported": "Успешно импортировано",
|
||||||
|
"Successfully restored": "Успешно восстановлено",
|
||||||
|
"System settings": "Системные настройки",
|
||||||
|
"Theme": "Тема",
|
||||||
|
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||||
|
"Toggle full page width": "Переключить ширину на всю страницу",
|
||||||
|
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
|
||||||
|
"untitled": "без названия",
|
||||||
|
"Untitled": "Без названия",
|
||||||
|
"Updated successfully": "Обновлено успешно",
|
||||||
|
"User": "Пользователь",
|
||||||
|
"Workspace": "Рабочее пространство",
|
||||||
|
"Workspace Name": "Имя рабочего пространства",
|
||||||
|
"Workspace settings": "Настройки рабочего пространства",
|
||||||
|
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
||||||
|
"Your Email": "Ваш адрес электронной почты",
|
||||||
|
"Your import is complete.": "Ваш импорт завершен.",
|
||||||
|
"Your name": "Ваше имя",
|
||||||
|
"Your Name": "Ваше Имя",
|
||||||
|
"Your password": "Ваш пароль",
|
||||||
|
"Your password must be a minimum of 8 characters.": "Ваш пароль должен содержать минимум 8 символов.",
|
||||||
|
"Sidebar toggle": "Переключить боковую панель",
|
||||||
|
"Comments": "Комментарии",
|
||||||
|
"404 page not found": "404 страница не найдена",
|
||||||
|
"Sorry, we can't find the page you are looking for.": "К сожалению, мы не можем найти страницу, которую вы ищете.",
|
||||||
|
"Take me back to homepage": "Вернуться на главную страницу",
|
||||||
|
"Forgot password": "Забыли пароль",
|
||||||
|
"Forgot your password?": "Забыли пароль?",
|
||||||
|
"A password reset link has been sent to your email. Please check your inbox.": "Ссылка для сброса пароля была отправлена на ваш электронный адрес. Пожалуйста, проверьте входящие сообщения.",
|
||||||
|
"Send reset link": "Отправить ссылку для сброса",
|
||||||
|
"Password reset": "Сброс пароля",
|
||||||
|
"Your new password": "Ваш новый пароль",
|
||||||
|
"Set password": "Установить пароль",
|
||||||
|
"Write a comment": "Написать комментарий",
|
||||||
|
"Reply...": "Ответить...",
|
||||||
|
"Error loading comments.": "Ошибка при загрузке комментариев.",
|
||||||
|
"No comments yet.": "Комментариев пока нет.",
|
||||||
|
"Edit comment": "Редактировать комментарий",
|
||||||
|
"Delete comment": "Удалить комментарий",
|
||||||
|
"Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?",
|
||||||
|
"Comment created successfully": "Комментарий успешно создан",
|
||||||
|
"Error creating comment": "Ошибка при создании комментария",
|
||||||
|
"Comment updated successfully": "Комментарий успешно обновлён",
|
||||||
|
"Failed to update comment": "Не удалось обновить комментарий",
|
||||||
|
"Comment deleted successfully": "Комментарий успешно удалён",
|
||||||
|
"Failed to delete comment": "Не удалось удалить комментарий",
|
||||||
|
"Comment resolved successfully": "Комментарий успешно разрешён",
|
||||||
|
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||||
|
"Revoke invitation": "Отозвать приглашение",
|
||||||
|
"Revoke": "Отозвать",
|
||||||
|
"Don't": "Нет",
|
||||||
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
|
||||||
|
"Resend invitation": "Отправить приглашение повторно",
|
||||||
|
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
|
||||||
|
"Invite link": "Ссылка для приглашения",
|
||||||
|
"Copy": "Копировать",
|
||||||
|
"Copied": "Скопировано",
|
||||||
|
"Select a user": "Выберите пользователя",
|
||||||
|
"Select a group": "Выберите группу",
|
||||||
|
"Export all pages and attachments in this space.": "Экспортировать все страницы и вложения в этом пространстве.",
|
||||||
|
"Delete space": "Удалить пространство",
|
||||||
|
"Are you sure you want to delete this space?": "Вы уверены, что хотите удалить это пространство?",
|
||||||
|
"Delete this space with all its pages and data.": "Удалить это пространство со всеми его страницами и данными.",
|
||||||
|
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Все страницы, комментарии, вложения и разрешения в этом пространстве будут удалены безвозвратно.",
|
||||||
|
"Confirm space name": "Подтвердите название пространства",
|
||||||
|
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введите название пространства <b>{{spaceName}}</b>, чтобы подтвердить ваше действие.",
|
||||||
|
"Format": "Формат",
|
||||||
|
"Include subpages": "Включить вложенные страницы",
|
||||||
|
"Include attachments": "Включить вложения",
|
||||||
|
"Select export format": "Выберите формат экспорта",
|
||||||
|
"Export failed:": "Экспортирование не удалось:",
|
||||||
|
"export error": "ошибка экспорта",
|
||||||
|
"Export page": "Экспорт страницы",
|
||||||
|
"Export space": "Экспорт пространства",
|
||||||
|
"Export {{type}}": "Экспорт {{type}}",
|
||||||
|
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||||
|
"Align left": "По левому краю",
|
||||||
|
"Align right": "По правому краю",
|
||||||
|
"Align center": "По центру",
|
||||||
|
"Merge cells": "Объединить ячейки",
|
||||||
|
"Split cell": "Разделить ячейку",
|
||||||
|
"Delete column": "Удалить столбец",
|
||||||
|
"Delete row": "Удалить строку",
|
||||||
|
"Add left column": "Добавить левый столбец",
|
||||||
|
"Add right column": "Добавить правый столбец",
|
||||||
|
"Add row above": "Добавить строку выше",
|
||||||
|
"Add row below": "Добавить строку ниже",
|
||||||
|
"Delete table": "Удалить таблицу",
|
||||||
|
"Info": "Информация",
|
||||||
|
"Success": "Успешно",
|
||||||
|
"Warning": "Предупреждение",
|
||||||
|
"Danger": "Важно",
|
||||||
|
"Mermaid diagram error:": "Ошибка диаграммы Mermaid:",
|
||||||
|
"Invalid Mermaid diagram": "Недопустимая диаграмма Mermaid",
|
||||||
|
"Double-click to edit Draw.io diagram": "Кликните дважды для редактирования диаграммы Draw.io",
|
||||||
|
"Exit": "Выйти",
|
||||||
|
"Save & Exit": "Сохранить и выйти",
|
||||||
|
"Double-click to edit Excalidraw diagram": "Кликните дважды для редактирования диаграммы Excalidraw",
|
||||||
|
"Paste link": "Вставить ссылку",
|
||||||
|
"Edit link": "Редактировать ссылку",
|
||||||
|
"Remove link": "Удалить ссылку",
|
||||||
|
"Add link": "Добавить ссылку",
|
||||||
|
"Please enter a valid url": "Пожалуйста, введите корректный url",
|
||||||
|
"Empty equation": "Пустое выражение",
|
||||||
|
"Invalid equation": "Недопустимое уравнение",
|
||||||
|
"Color": "Цвет",
|
||||||
|
"Text color": "Цвет текста",
|
||||||
|
"Default": "По умолчанию",
|
||||||
|
"Blue": "Синий",
|
||||||
|
"Green": "Зелёный",
|
||||||
|
"Purple": "Фиолетовый",
|
||||||
|
"Red": "Красный",
|
||||||
|
"Yellow": "Жёлтый",
|
||||||
|
"Orange": "Оранжевый",
|
||||||
|
"Pink": "Розовый",
|
||||||
|
"Gray": "Серый",
|
||||||
|
"Embed link": "Встроенная ссылка",
|
||||||
|
"Invalid {{provider}} embed link": "Неверная ссылка для встраивания {{provider}}",
|
||||||
|
"Embed {{provider}}": "Встроить {{provider}}",
|
||||||
|
"Enter {{provider}} link to embed": "Введите ссылку для встраивания {{provider}}",
|
||||||
|
"Bold": "Жирный",
|
||||||
|
"Italic": "Курсив",
|
||||||
|
"Underline": "Подчёркнутый",
|
||||||
|
"Strike": "Перечёркнутый",
|
||||||
|
"Code": "Код",
|
||||||
|
"Comment": "Комментарий",
|
||||||
|
"Text": "Текст",
|
||||||
|
"Heading 1": "Заголовок 1",
|
||||||
|
"Heading 2": "Заголовок 2",
|
||||||
|
"Heading 3": "Заголовок 3",
|
||||||
|
"To-do List": "Список дел",
|
||||||
|
"Bullet List": "Маркированный список",
|
||||||
|
"Numbered List": "Нумерованный список",
|
||||||
|
"Blockquote": "Блок цитирования",
|
||||||
|
"Just start typing with plain text.": "Просто начните печатать обычный текст.",
|
||||||
|
"Track tasks with a to-do list.": "Отследить задачи с помощью списка дел.",
|
||||||
|
"Big section heading.": "Большой заголовок раздела.",
|
||||||
|
"Medium section heading.": "Средний заголовок раздела.",
|
||||||
|
"Small section heading.": "Маленький заголовок раздела.",
|
||||||
|
"Create a simple bullet list.": "Создать простой маркированный список.",
|
||||||
|
"Create a list with numbering.": "Создать нумерованный список.",
|
||||||
|
"Create block quote.": "Создать блок цитирования.",
|
||||||
|
"Insert code snippet.": "Вставить фрагмент кода.",
|
||||||
|
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
|
||||||
|
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
||||||
|
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
||||||
|
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
||||||
|
"Table": "Таблица",
|
||||||
|
"Insert a table.": "Вставить таблицу.",
|
||||||
|
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
||||||
|
"Video": "Видео",
|
||||||
|
"Divider": "Разделитель",
|
||||||
|
"Quote": "Цитата",
|
||||||
|
"Image": "Изображение",
|
||||||
|
"File attachment": "Прикрепленный файл",
|
||||||
|
"Toggle block": "Переключить блок",
|
||||||
|
"Callout": "Выноска",
|
||||||
|
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||||
|
"Math inline": "Формула в строке",
|
||||||
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
|
"Math block": "Блок формул",
|
||||||
|
"Insert math equation": "Вставить математическое выражение",
|
||||||
|
"Mermaid diagram": "Диаграмма Mermaid",
|
||||||
|
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
|
||||||
|
"Insert and design Drawio diagrams": "Вставьте и редактируйте диаграммы Draw.io",
|
||||||
|
"Insert current date": "Вставить текущую дату",
|
||||||
|
"Draw and sketch excalidraw diagrams": "Создайте и рисуйте диаграммы Excalidraw",
|
||||||
|
"Multiple": "Несколько",
|
||||||
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
|
"Toggle title": "Переключить заголовок",
|
||||||
|
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для выбора команд",
|
||||||
|
"Names do not match": "Названия не совпадают",
|
||||||
|
"Today, {{time}}": "Сегодня, {{time}}",
|
||||||
|
"Yesterday, {{time}}": "Вчера, {{time}}"
|
||||||
|
}
|
||||||
@@ -10,14 +10,6 @@ import Groups from "@/pages/settings/group/groups";
|
|||||||
import GroupInfo from "./pages/settings/group/group-info";
|
import GroupInfo from "./pages/settings/group/group-info";
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
|
||||||
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
|
|
||||||
import { SOCKET_URL } from "@/features/websocket/types";
|
|
||||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
import SpaceHome from "@/pages/space/space-home.tsx";
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
@@ -30,35 +22,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [, setSocket] = useAtom(socketAtom);
|
|
||||||
const authToken = useAtomValue(authTokensAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authToken?.accessToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newSocket = io(SOCKET_URL, {
|
|
||||||
transports: ["websocket"],
|
|
||||||
auth: {
|
|
||||||
token: authToken.accessToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
setSocket(newSocket);
|
|
||||||
|
|
||||||
newSocket.on("connect", () => {
|
|
||||||
console.log("ws connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log("ws disconnected");
|
|
||||||
newSocket.disconnect();
|
|
||||||
};
|
|
||||||
}, [authToken?.accessToken]);
|
|
||||||
|
|
||||||
useQuerySubscription();
|
|
||||||
useTreeSocket();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Table, Text } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface NoTableResultsProps {
|
||||||
|
colSpan: number;
|
||||||
|
}
|
||||||
|
export default function NoTableResults({ colSpan }: NoTableResultsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={colSpan}>
|
||||||
|
<Text fw={500} c="dimmed" ta="center">
|
||||||
|
{t("No results found...")}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Button, Group } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface PagePaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
onPageChange: (newPage: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Paginate({
|
||||||
|
currentPage,
|
||||||
|
hasPrevPage,
|
||||||
|
hasNextPage,
|
||||||
|
onPageChange,
|
||||||
|
}: PagePaginationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!hasPrevPage && !hasNextPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={!hasPrevPage}
|
||||||
|
>
|
||||||
|
{t("Prev")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
>
|
||||||
|
{t("Next")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { TextInput, Group } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface SearchInputProps {
|
||||||
|
placeholder?: string;
|
||||||
|
debounceDelay?: number;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchInput({
|
||||||
|
placeholder,
|
||||||
|
debounceDelay = 500,
|
||||||
|
onSearch,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [debouncedValue] = useDebouncedValue(value, debounceDelay);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSearch(debouncedValue);
|
||||||
|
}, [debouncedValue, onSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mb="sm">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
placeholder={placeholder || t("Search...")}
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export default function TopMenu() {
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
{workspace.name}
|
{workspace.name}
|
||||||
</Text>
|
</Text>
|
||||||
<IconChevronDown size={16} />
|
<IconChevronDown size={16} />
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
||||||
import { ITokens } from "../types/auth.types";
|
|
||||||
|
|
||||||
const cookieStorage = createJSONStorage<ITokens>(() => {
|
const cookieStorage = createJSONStorage<any>(() => {
|
||||||
return {
|
return {
|
||||||
getItem: () => Cookies.get("authTokens"),
|
getItem: () => Cookies.get("authTokens"),
|
||||||
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
|
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
|
||||||
@@ -10,7 +9,7 @@ const cookieStorage = createJSONStorage<ITokens>(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authTokensAtom = atomWithStorage<ITokens | null>(
|
export const authTokensAtom = atomWithStorage<any | null>(
|
||||||
"authTokens",
|
"authTokens",
|
||||||
null,
|
null,
|
||||||
cookieStorage,
|
cookieStorage,
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ import * as z from "zod";
|
|||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import { IPasswordReset } from "@/features/auth/types/auth.types";
|
import { IPasswordReset } from "@/features/auth/types/auth.types";
|
||||||
import {
|
import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
PasswordInput,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
login,
|
login,
|
||||||
|
logout,
|
||||||
passwordReset,
|
passwordReset,
|
||||||
setupWorkspace,
|
setupWorkspace,
|
||||||
verifyUserToken,
|
verifyUserToken,
|
||||||
} from "@/features/auth/services/auth-service";
|
} from "@/features/auth/services/auth-service";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
IForgotPassword,
|
IForgotPassword,
|
||||||
@@ -20,31 +20,26 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { jwtDecode } from "jwt-decode";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { RESET } from "jotai/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleSignIn = async (data: ILogin) => {
|
const handleSignIn = async (data: ILogin) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setAuthToken(res.tokens);
|
|
||||||
|
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
console.log(err);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: err.response?.data.message,
|
message: err.response?.data.message,
|
||||||
color: "red",
|
color: "red",
|
||||||
@@ -56,11 +51,8 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await acceptInvitation(data);
|
await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
setAuthToken(res.tokens);
|
|
||||||
|
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -77,9 +69,6 @@ export default function useAuth() {
|
|||||||
try {
|
try {
|
||||||
const res = await setupWorkspace(data);
|
const res = await setupWorkspace(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
setAuthToken(res.tokens);
|
|
||||||
|
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -94,14 +83,11 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await passwordReset(data);
|
await passwordReset(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
setAuthToken(res.tokens);
|
|
||||||
|
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: "Password reset was successful",
|
message: t("Password reset was successful"),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -112,34 +98,10 @@ export default function useAuth() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIsAuthenticated = async () => {
|
|
||||||
if (!authToken) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessToken = authToken.accessToken;
|
|
||||||
const payload = jwtDecode(accessToken);
|
|
||||||
|
|
||||||
// true if jwt is active
|
|
||||||
const now = Date.now().valueOf() / 1000;
|
|
||||||
return payload.exp >= now;
|
|
||||||
} catch (err) {
|
|
||||||
console.log("invalid jwt token", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasTokens = (): boolean => {
|
|
||||||
return !!authToken;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setAuthToken(null);
|
setCurrentUser(RESET);
|
||||||
setCurrentUser(null);
|
await logout();
|
||||||
Cookies.remove("authTokens");
|
window.location.replace(APP_ROUTE.AUTH.LOGIN);
|
||||||
queryClient.clear();
|
|
||||||
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForgotPassword = async (data: IForgotPassword) => {
|
const handleForgotPassword = async (data: IForgotPassword) => {
|
||||||
@@ -182,12 +144,10 @@ export default function useAuth() {
|
|||||||
signIn: handleSignIn,
|
signIn: handleSignIn,
|
||||||
invitationSignup: handleInvitationSignUp,
|
invitationSignup: handleInvitationSignUp,
|
||||||
setupWorkspace: handleSetupWorkspace,
|
setupWorkspace: handleSetupWorkspace,
|
||||||
isAuthenticated: handleIsAuthenticated,
|
|
||||||
forgotPassword: handleForgotPassword,
|
forgotPassword: handleForgotPassword,
|
||||||
passwordReset: handlePasswordReset,
|
passwordReset: handlePasswordReset,
|
||||||
verifyUserToken: handleVerifyUserToken,
|
verifyUserToken: handleVerifyUserToken,
|
||||||
logout: handleLogout,
|
logout: handleLogout,
|
||||||
hasTokens,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
|
||||||
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
|
||||||
|
|
||||||
export function useRedirectIfAuthenticated() {
|
export function useRedirectIfAuthenticated() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { data, isLoading } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = async () => {
|
if (data && data?.user) {
|
||||||
const validAuth = await isAuthenticated();
|
navigate(APP_ROUTE.HOME);
|
||||||
if (validAuth) {
|
}
|
||||||
navigate("/home");
|
}, [isLoading, data]);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAuth();
|
|
||||||
}, [isAuthenticated]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
import { verifyUserToken } from "../services/auth-service";
|
import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||||
import { IVerifyUserToken } from "../types/auth.types";
|
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||||
|
|
||||||
export function useVerifyUserTokenQuery(
|
export function useVerifyUserTokenQuery(
|
||||||
verify: IVerifyUserToken,
|
verify: IVerifyUserToken,
|
||||||
): UseQueryResult<any, Error> {
|
): UseQueryResult<any, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["verify-token", verify],
|
queryKey: ["verify-token", verify],
|
||||||
queryFn: () => verifyUserToken(verify),
|
queryFn: () => verifyUserToken(verify),
|
||||||
enabled: !!verify.token,
|
enabled: !!verify.token,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["collab-token"],
|
||||||
|
queryFn: () => getCollabToken(),
|
||||||
|
staleTime: 24 * 60 * 60 * 1000, //24hrs
|
||||||
|
refetchInterval: 20 * 60 * 60 * 1000, //20hrs
|
||||||
|
retry: 10,
|
||||||
|
retryDelay: (retryAttempt) => {
|
||||||
|
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||||
|
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,49 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
IChangePassword,
|
IChangePassword,
|
||||||
|
ICollabToken,
|
||||||
IForgotPassword,
|
IForgotPassword,
|
||||||
ILogin,
|
ILogin,
|
||||||
IPasswordReset,
|
IPasswordReset,
|
||||||
IRegister,
|
|
||||||
ISetupWorkspace,
|
ISetupWorkspace,
|
||||||
ITokenResponse,
|
|
||||||
IVerifyUserToken,
|
IVerifyUserToken,
|
||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
|
|
||||||
export async function login(data: ILogin): Promise<ITokenResponse> {
|
export async function login(data: ILogin): Promise<void> {
|
||||||
const req = await api.post<ITokenResponse>("/auth/login", data);
|
await api.post<void>("/auth/login", data);
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
export async function logout(): Promise<void> {
|
||||||
export async function register(data: IRegister): Promise<ITokenResponse> {
|
await api.post<void>("/auth/logout");
|
||||||
const req = await api.post<ITokenResponse>("/auth/register", data);
|
}
|
||||||
return req.data;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
data: IChangePassword
|
data: IChangePassword,
|
||||||
): Promise<IChangePassword> {
|
): Promise<IChangePassword> {
|
||||||
const req = await api.post<IChangePassword>("/auth/change-password", data);
|
const req = await api.post<IChangePassword>("/auth/change-password", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupWorkspace(
|
export async function setupWorkspace(
|
||||||
data: ISetupWorkspace
|
data: ISetupWorkspace,
|
||||||
): Promise<ITokenResponse> {
|
): Promise<any> {
|
||||||
const req = await api.post<ITokenResponse>("/auth/setup", data);
|
const req = await api.post<any>("/auth/setup", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
||||||
await api.post<any>("/auth/forgot-password", data);
|
await api.post<void>("/auth/forgot-password", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function passwordReset(
|
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||||
data: IPasswordReset
|
await api.post<void>("/auth/password-reset", data);
|
||||||
): Promise<ITokenResponse> {
|
|
||||||
const req = await api.post<any>("/auth/password-reset", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||||
return api.post<any>("/auth/verify-token", data);
|
return api.post<any>("/auth/verify-token", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCollabToken(): Promise<ICollabToken> {
|
||||||
|
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ export interface ISetupWorkspace {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITokens {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITokenResponse {
|
|
||||||
tokens: ITokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IChangePassword {
|
export interface IChangePassword {
|
||||||
oldPassword: string;
|
oldPassword: string;
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
@@ -43,3 +34,7 @@ export interface IVerifyUserToken {
|
|||||||
token: string;
|
token: string;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ICollabToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import classes from "./bubble-menu.module.css";
|
|||||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||||
import { ColorSelector } from "./color-selector";
|
import { ColorSelector } from "./color-selector";
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
|
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||||
import {
|
import {
|
||||||
draftCommentIdAtom,
|
draftCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
@@ -117,6 +118,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsTextAlignmentOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
setIsLinkSelectorOpen(false);
|
||||||
},
|
},
|
||||||
@@ -124,6 +126,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
|
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
|
|
||||||
@@ -135,6 +138,20 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
isOpen={isNodeSelectorOpen}
|
isOpen={isNodeSelectorOpen}
|
||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||||
|
setIsTextAlignmentOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAlignmentSelector
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isTextAlignmentSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -162,6 +179,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsTextAlignmentOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -170,6 +190,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
isOpen={isColorSelectorOpen}
|
isOpen={isColorSelectorOpen}
|
||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsColorSelectorOpen(!isColorSelectorOpen);
|
setIsColorSelectorOpen(!isColorSelectorOpen);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsTextAlignmentOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||||
|
import {
|
||||||
|
IconAlignCenter,
|
||||||
|
IconAlignJustified,
|
||||||
|
IconAlignLeft,
|
||||||
|
IconAlignRight,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronDown,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
|
||||||
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface TextAlignmentProps {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BubbleMenuItem {
|
||||||
|
name: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
command: () => void;
|
||||||
|
isActive: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||||
|
editor,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const items: BubbleMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: "Align left",
|
||||||
|
isActive: () => editor.isActive({ textAlign: "left" }),
|
||||||
|
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||||
|
icon: IconAlignLeft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Align center",
|
||||||
|
isActive: () => editor.isActive({ textAlign: "center" }),
|
||||||
|
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||||
|
icon: IconAlignCenter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Align right",
|
||||||
|
isActive: () => editor.isActive({ textAlign: "right" }),
|
||||||
|
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||||
|
icon: IconAlignRight,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Justify",
|
||||||
|
isActive: () => editor.isActive({ textAlign: "justify" }),
|
||||||
|
command: () => editor.chain().focus().setTextAlign("justify").run(),
|
||||||
|
icon: IconAlignJustified,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
|
name: "Multiple",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover opened={isOpen} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
style={{ border: "none", height: "34px" }}
|
||||||
|
px="5"
|
||||||
|
radius="0"
|
||||||
|
rightSection={<IconChevronDown size={16} />}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<IconAlignLeft style={{ width: rem(16) }} stroke={2} />
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<ScrollArea.Autosize type="scroll" mah={400}>
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<item.icon size={16} />}
|
||||||
|
rightSection={
|
||||||
|
activeItem.name === item.name && <IconCheck size={16} />
|
||||||
|
}
|
||||||
|
justify="left"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => {
|
||||||
|
item.command();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
{t(item.name)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Button.Group>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import classes from './code-block.module.css';
|
import classes from "./code-block.module.css";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const MermaidView = React.lazy(
|
const MermaidView = React.lazy(
|
||||||
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
|
() => import("@/features/editor/components/code-block/mermaid-view.tsx"),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function CodeBlockView(props: NodeViewProps) {
|
export default function CodeBlockView(props: NodeViewProps) {
|
||||||
@@ -16,7 +16,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
const { node, updateAttributes, extension, editor, getPos } = props;
|
const { node, updateAttributes, extension, editor, getPos } = props;
|
||||||
const { language } = node.attrs;
|
const { language } = node.attrs;
|
||||||
const [languageValue, setLanguageValue] = useState<string | null>(
|
const [languageValue, setLanguageValue] = useState<string | null>(
|
||||||
language || null
|
language || null,
|
||||||
);
|
);
|
||||||
const [isSelected, setIsSelected] = useState(false);
|
const [isSelected, setIsSelected] = useState(false);
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
setIsSelected(isNodeSelected);
|
setIsSelected(isNodeSelected);
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.on('selectionUpdate', updateSelection);
|
editor.on("selectionUpdate", updateSelection);
|
||||||
return () => {
|
return () => {
|
||||||
editor.off('selectionUpdate', updateSelection);
|
editor.off("selectionUpdate", updateSelection);
|
||||||
};
|
};
|
||||||
}, [editor, getPos(), node.nodeSize]);
|
}, [editor, getPos(), node.nodeSize]);
|
||||||
|
|
||||||
@@ -46,7 +46,11 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="codeBlock">
|
<NodeViewWrapper className="codeBlock">
|
||||||
<Group justify="flex-end" contentEditable={false}>
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
contentEditable={false}
|
||||||
|
className={classes.menuGroup}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
placeholder="auto"
|
placeholder="auto"
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
@@ -54,7 +58,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
value={languageValue}
|
value={languageValue}
|
||||||
onChange={changeLanguage}
|
onChange={changeLanguage}
|
||||||
searchable
|
searchable
|
||||||
style={{ maxWidth: '130px' }}
|
style={{ maxWidth: "130px" }}
|
||||||
classNames={{ input: classes.selectInput }}
|
classNames={{ input: classes.selectInput }}
|
||||||
disabled={!editor.isEditable}
|
disabled={!editor.isEditable}
|
||||||
/>
|
/>
|
||||||
@@ -62,12 +66,12 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
<CopyButton value={node?.textContent} timeout={2000}>
|
<CopyButton value={node?.textContent} timeout={2000}>
|
||||||
{({ copied, copy }) => (
|
{({ copied, copy }) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={copied ? t('Copied') : t('Copy')}
|
label={copied ? t("Copied") : t("Copy")}
|
||||||
withArrow
|
withArrow
|
||||||
position="right"
|
position="right"
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color={copied ? 'teal' : 'gray'}
|
color={copied ? "teal" : "gray"}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
>
|
>
|
||||||
@@ -81,15 +85,15 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
<pre
|
<pre
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
hidden={
|
hidden={
|
||||||
((language === 'mermaid' && !editor.isEditable) ||
|
((language === "mermaid" && !editor.isEditable) ||
|
||||||
(language === 'mermaid' && !isSelected)) &&
|
(language === "mermaid" && !isSelected)) &&
|
||||||
node.textContent.length > 0
|
node.textContent.length > 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NodeViewContent as="code" className={`language-${language}`} />
|
<NodeViewContent as="code" className={`language-${language}`} />
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
{language === 'mermaid' && (
|
{language === "mermaid" && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MermaidView props={props} />
|
<MermaidView props={props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -16,3 +16,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuGroup {
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+31
-1
@@ -2,12 +2,42 @@ import type { EditorView } from "@tiptap/pm/view";
|
|||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
|
import { Slice } from "@tiptap/pm/model";
|
||||||
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
|
|
||||||
export const handleFilePaste = (
|
export const handlePaste = (
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
creatorId?: string,
|
||||||
) => {
|
) => {
|
||||||
|
const clipboardData = event.clipboardData.getData("text/plain");
|
||||||
|
|
||||||
|
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
||||||
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
|
event.preventDefault();
|
||||||
|
const url = clipboardData.trim();
|
||||||
|
const { from: pos, empty } = view.state.selection;
|
||||||
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
|
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||||
|
|
||||||
|
// pasted link must be from the same workspace/domain and must not be on a selection
|
||||||
|
if (!empty || match[2] !== window.location.host) {
|
||||||
|
// allow the default link extension to handle this
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// for now, we only support internal links from the same space
|
||||||
|
// compare space name
|
||||||
|
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMentionAction(url, view, pos, creatorId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.clipboardData?.files.length) {
|
if (event.clipboardData?.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const [file] = Array.from(event.clipboardData.files);
|
const [file] = Array.from(event.clipboardData.files);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
|
import { getPageById } from "@/features/page/services/page-service.ts";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import { v7 } from "uuid";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
|
||||||
|
export type LinkFn = (
|
||||||
|
url: string,
|
||||||
|
view: EditorView,
|
||||||
|
pos: number,
|
||||||
|
creatorId: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export interface InternalLinkOptions {
|
||||||
|
validateFn: (url: string, view: EditorView) => boolean;
|
||||||
|
onResolveLink: (linkedPageId: string, creatorId: string) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleInternalLink =
|
||||||
|
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
|
||||||
|
async (url: string, view, pos, creatorId) => {
|
||||||
|
const validated = validateFn(url, view);
|
||||||
|
if (!validated) return;
|
||||||
|
|
||||||
|
const linkedPageId = extractPageSlugId(url);
|
||||||
|
|
||||||
|
await onResolveLink(linkedPageId, creatorId).then(
|
||||||
|
(page: IPage) => {
|
||||||
|
const { schema } = view.state;
|
||||||
|
|
||||||
|
const node = schema.nodes.mention.create({
|
||||||
|
id: v7(),
|
||||||
|
label: page.title || "Untitled",
|
||||||
|
entityType: "page",
|
||||||
|
entityId: page.id,
|
||||||
|
slugId: page.slugId,
|
||||||
|
creatorId: creatorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const transaction = view.state.tr.replaceWith(pos, pos, node);
|
||||||
|
view.dispatch(transaction);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// on failure, insert as normal link
|
||||||
|
const { schema } = view.state;
|
||||||
|
|
||||||
|
const transaction = view.state.tr.insertText(url, pos);
|
||||||
|
transaction.addMark(
|
||||||
|
pos,
|
||||||
|
pos + url.length,
|
||||||
|
schema.marks.link.create({ href: url }),
|
||||||
|
);
|
||||||
|
|
||||||
|
view.dispatch(transaction);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMentionAction = handleInternalLink({
|
||||||
|
onResolveLink: async (linkedPageId: string): Promise<any> => {
|
||||||
|
// eslint-disable-next-line no-useless-catch
|
||||||
|
try {
|
||||||
|
return await getPageById({ pageId: linkedPageId });
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateFn: (url: string, view: EditorView) => {
|
||||||
|
// validation is already done on the paste handler
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./link.module.css";
|
||||||
|
|
||||||
export type LinkPreviewPanelProps = {
|
export type LinkPreviewPanelProps = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -31,12 +32,7 @@ export const LinkPreviewPanel = ({
|
|||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
inherit
|
className={classes.link}
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.link {
|
||||||
|
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./mention.module.css";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { v7 as uuid7 } from "uuid";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import {
|
||||||
|
MentionListProps,
|
||||||
|
MentionSuggestionItem,
|
||||||
|
} from "@/features/editor/components/mention/mention.type.ts";
|
||||||
|
|
||||||
|
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||||
|
|
||||||
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
|
query: props.query,
|
||||||
|
includeUsers: true,
|
||||||
|
includePages: true,
|
||||||
|
spaceId: space.id,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestion && !isLoading) {
|
||||||
|
let items: MentionSuggestionItem[] = [];
|
||||||
|
|
||||||
|
if (suggestion?.users?.length > 0) {
|
||||||
|
items.push({ entityType: "header", label: "Users" });
|
||||||
|
|
||||||
|
items = items.concat(
|
||||||
|
suggestion.users.map((user) => ({
|
||||||
|
id: uuid7(),
|
||||||
|
label: user.name,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: user.id,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestion?.pages?.length > 0) {
|
||||||
|
items.push({ entityType: "header", label: "Pages" });
|
||||||
|
items = items.concat(
|
||||||
|
suggestion.pages.map((page) => ({
|
||||||
|
id: uuid7(),
|
||||||
|
label: page.title || "Untitled",
|
||||||
|
entityType: "page",
|
||||||
|
entityId: page.id,
|
||||||
|
slugId: page.slugId,
|
||||||
|
icon: page.icon,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRenderItems(items);
|
||||||
|
// update editor storage
|
||||||
|
props.editor.storage.mentionItems = items;
|
||||||
|
}
|
||||||
|
}, [suggestion, isLoading]);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = renderItems?.[index];
|
||||||
|
if (item) {
|
||||||
|
if (item.entityType === "user") {
|
||||||
|
props.command({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: item.entityId,
|
||||||
|
creatorId: currentUser?.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.entityType === "page") {
|
||||||
|
props.command({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label || "Untitled",
|
||||||
|
entityType: "page",
|
||||||
|
entityId: item.entityId,
|
||||||
|
slugId: item.slugId,
|
||||||
|
creatorId: currentUser?.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[renderItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const upHandler = () => {
|
||||||
|
if (!renderItems.length) return;
|
||||||
|
|
||||||
|
let newIndex = selectedIndex;
|
||||||
|
|
||||||
|
do {
|
||||||
|
newIndex = (newIndex + renderItems.length - 1) % renderItems.length;
|
||||||
|
} while (renderItems[newIndex].entityType === "header");
|
||||||
|
setSelectedIndex(newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downHandler = () => {
|
||||||
|
if (!renderItems.length) return;
|
||||||
|
let newIndex = selectedIndex;
|
||||||
|
do {
|
||||||
|
newIndex = (newIndex + 1) % renderItems.length;
|
||||||
|
} while (renderItems[newIndex].entityType === "header");
|
||||||
|
setSelectedIndex(newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterHandler = () => {
|
||||||
|
if (!renderItems.length) return;
|
||||||
|
if (renderItems[selectedIndex].entityType !== "header") {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(1);
|
||||||
|
}, [suggestion]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }) => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
upHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
downHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
// don't trap the enter button if there are no items to render
|
||||||
|
if (renderItems.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
enterHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// if no results and enter what to do?
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewportRef.current
|
||||||
|
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
if (renderItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<Paper shadow="md" p="xs" withBorder>
|
||||||
|
No results
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper id="mention" shadow="md" p="xs" withBorder>
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
viewportRef={viewportRef}
|
||||||
|
mah={350}
|
||||||
|
w={320}
|
||||||
|
scrollbarSize={8}
|
||||||
|
>
|
||||||
|
{renderItems?.map((item, index) => {
|
||||||
|
if (item.entityType === "header") {
|
||||||
|
return (
|
||||||
|
<div key={`${item.label}-${index}`}>
|
||||||
|
<Text c="dimmed" mb={4} tt="uppercase">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (item.entityType === "user") {
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
data-item-index={index}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
className={clsx(classes.menuBtn, {
|
||||||
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<CustomAvatar
|
||||||
|
size={"sm"}
|
||||||
|
avatarUrl={item.avatarUrl}
|
||||||
|
name={item.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
} else if (item.entityType === "page") {
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
data-item-index={index}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
className={clsx(classes.menuBtn, {
|
||||||
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
component="div"
|
||||||
|
aria-label={item.label}
|
||||||
|
>
|
||||||
|
{item.icon || (
|
||||||
|
<ActionIcon
|
||||||
|
component="span"
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
size={18}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MentionList;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||||
|
|
||||||
|
function getWhitespaceCount(query: string) {
|
||||||
|
const matches = query?.match(/([\s]+)/g);
|
||||||
|
return matches?.length || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionRenderItems = () => {
|
||||||
|
let component: ReactRenderer | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
clientRect: DOMRect;
|
||||||
|
query: string;
|
||||||
|
}) => {
|
||||||
|
// query must not start with a whitespace
|
||||||
|
if (props.query.charAt(0) === ' '){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't render component if space between the search query words is greater than 4
|
||||||
|
const whitespaceCount = getWhitespaceCount(props.query);
|
||||||
|
if (whitespaceCount > 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
component = new ReactRenderer(MentionList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate: (props: {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
clientRect: DOMRect;
|
||||||
|
query: string;
|
||||||
|
}) => {
|
||||||
|
// query must not start with a whitespace
|
||||||
|
if (props.query.charAt(0) === ' '){
|
||||||
|
component?.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only update component if popup is not destroyed
|
||||||
|
if (!popup?.[0].state.isDestroyed) {
|
||||||
|
component?.updateProps(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props || !props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whitespaceCount = getWhitespaceCount(props.query);
|
||||||
|
|
||||||
|
// destroy component if space is greater 3 without a match
|
||||||
|
if (
|
||||||
|
whitespaceCount > 3 &&
|
||||||
|
props.editor.storage.mentionItems.length === 0
|
||||||
|
) {
|
||||||
|
popup?.[0]?.destroy();
|
||||||
|
component?.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
!popup?.[0].state.isDestroyed &&
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
|
if (props.event.key)
|
||||||
|
if (
|
||||||
|
props.event.key === "Escape" ||
|
||||||
|
(props.event.key === "Enter" && !popup?.[0].state.isShown)
|
||||||
|
) {
|
||||||
|
popup?.[0].destroy();
|
||||||
|
component?.destroy();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
if (popup && !popup?.[0].state.isDestroyed) {
|
||||||
|
popup[0].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mentionRenderItems;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||||
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
const { node } = props;
|
||||||
|
const { label, entityType, entityId, slugId } = node.attrs;
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const {
|
||||||
|
data: page,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }}>
|
||||||
|
{entityType === "user" && (
|
||||||
|
<Text className={classes.userMention} component="span">
|
||||||
|
@{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entityType === "page" && (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
fw={500}
|
||||||
|
to={buildPageUrl(spaceSlug, slugId, label)}
|
||||||
|
underline="never"
|
||||||
|
className={classes.pageMentionLink}
|
||||||
|
>
|
||||||
|
{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={classes.pageMentionText}>
|
||||||
|
{page?.title || label}
|
||||||
|
</span>
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.pageMentionLink {
|
||||||
|
color: light-dark(
|
||||||
|
var(--mantine-color-dark-4),
|
||||||
|
var(--mantine-color-dark-1)
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
.pageMentionText {
|
||||||
|
@mixin light {
|
||||||
|
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMention {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
&::after {
|
||||||
|
content: "\200B";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@mixin light {
|
||||||
|
background: var(--mantine-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: var(--mantine-color-gray-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedItem {
|
||||||
|
@mixin light {
|
||||||
|
background: var(--mantine-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: var(--mantine-color-gray-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Editor, Range } from "@tiptap/core";
|
||||||
|
|
||||||
|
export interface MentionListProps {
|
||||||
|
query: string;
|
||||||
|
command: any;
|
||||||
|
items: [];
|
||||||
|
range: Range;
|
||||||
|
text: string;
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MentionSuggestionItem =
|
||||||
|
| { entityType: "header"; label: string }
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
entityType: "user";
|
||||||
|
entityId: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
entityType: "page";
|
||||||
|
entityId: string;
|
||||||
|
slugId: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -64,8 +65,11 @@ import clojure from "highlight.js/lib/languages/clojure";
|
|||||||
import fortran from "highlight.js/lib/languages/fortran";
|
import fortran from "highlight.js/lib/languages/fortran";
|
||||||
import haskell from "highlight.js/lib/languages/haskell";
|
import haskell from "highlight.js/lib/languages/haskell";
|
||||||
import scala from "highlight.js/lib/languages/scala";
|
import scala from "highlight.js/lib/languages/scala";
|
||||||
|
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||||
import i18n from "i18next";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@@ -133,6 +137,23 @@ export const mainExtensions = [
|
|||||||
class: "comment-mark",
|
class: "comment-mark",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Mention.configure({
|
||||||
|
suggestion: {
|
||||||
|
allowSpaces: true,
|
||||||
|
items: () => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
render: mentionRenderItems,
|
||||||
|
},
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "mention",
|
||||||
|
},
|
||||||
|
}).extend({
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(MentionView);
|
||||||
|
},
|
||||||
|
}),
|
||||||
Table.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: false,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
@@ -36,11 +35,12 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
|||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handleFilePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -53,7 +53,6 @@ export default function PageEditor({
|
|||||||
editable,
|
editable,
|
||||||
content,
|
content,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const [token] = useAtom(authTokensAtom);
|
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(pageEditorAtom);
|
const [, setEditor] = useAtom(pageEditorAtom);
|
||||||
@@ -68,6 +67,7 @@ export default function PageEditor({
|
|||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
const documentName = `page.${pageId}`;
|
||||||
|
const { data } = useCollabToken();
|
||||||
|
|
||||||
const localProvider = useMemo(() => {
|
const localProvider = useMemo(() => {
|
||||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||||
@@ -77,14 +77,14 @@ export default function PageEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return provider;
|
return provider;
|
||||||
}, [pageId, ydoc]);
|
}, [pageId, ydoc, data?.token]);
|
||||||
|
|
||||||
const remoteProvider = useMemo(() => {
|
const remoteProvider = useMemo(() => {
|
||||||
const provider = new HocuspocusProvider({
|
const provider = new HocuspocusProvider({
|
||||||
name: documentName,
|
name: documentName,
|
||||||
url: collaborationURL,
|
url: collaborationURL,
|
||||||
document: ydoc,
|
document: ydoc,
|
||||||
token: token?.accessToken,
|
token: data?.token,
|
||||||
connect: false,
|
connect: false,
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
if (status.status === "connected") {
|
if (status.status === "connected") {
|
||||||
@@ -102,7 +102,7 @@ export default function PageEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return provider;
|
return provider;
|
||||||
}, [ydoc, pageId, token?.accessToken]);
|
}, [ydoc, pageId, data?.token]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
remoteProvider.connect();
|
remoteProvider.connect();
|
||||||
@@ -138,7 +138,8 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handlePaste: (view, event) => handleFilePaste(view, event, pageId),
|
handlePaste: (view, event, slice) =>
|
||||||
|
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||||
handleDrop: (view, event, _slice, moved) =>
|
handleDrop: (view, event, _slice, moved) =>
|
||||||
handleFileDrop(view, event, moved, pageId),
|
handleFileDrop(view, event, moved, pageId),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
margin: 4px;
|
margin: 4px;
|
||||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||||
border-radius: var(--mantine-radius-default);
|
border-radius: var(--mantine-radius-default);
|
||||||
|
tab-size: 4;
|
||||||
|
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background-color: var(--mantine-color-gray-0);
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
|||||||
@@ -56,8 +56,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: light-dark(#207af1, #587da9);
|
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
|
||||||
/*font-weight: bold;*/
|
@mixin light {
|
||||||
|
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
||||||
|
}
|
||||||
|
/*font-weight: 500; */
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,5 @@
|
|||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
@import "./print.css";
|
@import "./print.css";
|
||||||
|
@import "./mention.css";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.node-mention {
|
||||||
|
&.ProseMirror-selectednode {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +1,84 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||||
import React from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetGroupsQuery();
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading } = useGetGroupsQuery({ page });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data && (
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table.ScrollContainer minWidth={400}>
|
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
<Table.Thead>
|
||||||
<Table.Thead>
|
<Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Th>{t("Group")}</Table.Th>
|
||||||
<Table.Th>{t("Group")}</Table.Th>
|
<Table.Th>{t("Members")}</Table.Th>
|
||||||
<Table.Th>{t("Members")}</Table.Th>
|
</Table.Tr>
|
||||||
</Table.Tr>
|
</Table.Thead>
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((group: IGroup, index: number) => (
|
{data?.items.map((group: IGroup, index: number) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
underline="never"
|
underline="never"
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--mantine-color-text)",
|
color: "var(--mantine-color-text)",
|
||||||
}}
|
}}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/settings/groups/${group.id}`}
|
to={`/settings/groups/${group.id}`}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<IconGroupCircle />
|
<IconGroupCircle />
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{group.description}
|
{group.description}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
underline="never"
|
underline="never"
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--mantine-color-text)",
|
color: "var(--mantine-color-text)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/settings/groups/${group.id}`}
|
to={`/settings/groups/${group.id}`}
|
||||||
>
|
>
|
||||||
{formatMemberCount(group.memberCount, t)}
|
{formatMemberCount(group.memberCount, t)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
|
{data?.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import {
|
|||||||
useRemoveGroupMemberMutation,
|
useRemoveGroupMemberMutation,
|
||||||
} from "@/features/group/queries/group-query";
|
} from "@/features/group/queries/group-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
@@ -45,67 +47,71 @@ export default function GroupMembersList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data && (
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table.ScrollContainer minWidth={500}>
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<Table verticalSpacing="sm">
|
<Table.Thead>
|
||||||
<Table.Thead>
|
<Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Th>{t("User")}</Table.Th>
|
||||||
<Table.Th>{t("User")}</Table.Th>
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
<Table.Th></Table.Th>
|
||||||
<Table.Th></Table.Th>
|
</Table.Tr>
|
||||||
</Table.Tr>
|
</Table.Thead>
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((user: IUser, index: number) => (
|
{data?.items.map((user: IUser, index: number) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<CustomAvatar
|
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||||
avatarUrl={user.avatarUrl}
|
<div>
|
||||||
name={user.name}
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
/>
|
{user.name}
|
||||||
<div>
|
</Text>
|
||||||
<Text fz="sm" fw={500}>
|
<Text fz="xs" c="dimmed">
|
||||||
{user.name}
|
{user.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
</div>
|
||||||
{user.email}
|
</Group>
|
||||||
</Text>
|
</Table.Td>
|
||||||
</div>
|
<Table.Td>
|
||||||
</Group>
|
<Badge variant="light">{t("Active")}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="light">{t("Active")}</Badge>
|
{isAdmin && (
|
||||||
</Table.Td>
|
<Menu
|
||||||
<Table.Td>
|
shadow="xl"
|
||||||
{isAdmin && (
|
position="bottom-end"
|
||||||
<Menu
|
offset={20}
|
||||||
shadow="xl"
|
width={200}
|
||||||
position="bottom-end"
|
withArrow
|
||||||
offset={20}
|
arrowPosition="center"
|
||||||
width={200}
|
>
|
||||||
withArrow
|
<Menu.Target>
|
||||||
arrowPosition="center"
|
<ActionIcon variant="subtle" c="gray">
|
||||||
>
|
<IconDots size={20} stroke={2} />
|
||||||
<Menu.Target>
|
</ActionIcon>
|
||||||
<ActionIcon variant="subtle" c="gray">
|
</Menu.Target>
|
||||||
<IconDots size={20} stroke={2} />
|
<Menu.Dropdown>
|
||||||
</ActionIcon>
|
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||||
</Menu.Target>
|
{t("Remove group member")}
|
||||||
<Menu.Dropdown>
|
</Menu.Item>
|
||||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
</Menu.Dropdown>
|
||||||
{t("Remove group member")}
|
</Menu>
|
||||||
</Menu.Item>
|
)}
|
||||||
</Menu.Dropdown>
|
</Table.Td>
|
||||||
</Menu>
|
</Table.Tr>
|
||||||
)}
|
))}
|
||||||
</Table.Td>
|
</Table.Tbody>
|
||||||
</Table.Tr>
|
</Table>
|
||||||
))}
|
</Table.ScrollContainer>
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
{data?.items.length > 0 && (
|
||||||
</Table.ScrollContainer>
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ interface MultiUserSelectProps {
|
|||||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||||
option,
|
option,
|
||||||
}) => (
|
}) => (
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={option?.["avatarUrl"]}
|
avatarUrl={option?.["avatarUrl"]}
|
||||||
name={option.label}
|
name={option.label}
|
||||||
size={36}
|
size={36}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{option.label}</Text>
|
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||||
<Text size="xs" opacity={0.5}>
|
<Text size="xs" opacity={0.5}>
|
||||||
{option?.["email"]}
|
{option?.["email"]}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from '@tanstack/react-query';
|
keepPreviousData,
|
||||||
import { IGroup } from '@/features/group/types/group.types';
|
} from "@tanstack/react-query";
|
||||||
|
import { IGroup } from "@/features/group/types/group.types";
|
||||||
import {
|
import {
|
||||||
addGroupMember,
|
addGroupMember,
|
||||||
createGroup,
|
createGroup,
|
||||||
@@ -14,22 +15,24 @@ import {
|
|||||||
getGroups,
|
getGroups,
|
||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
} from '@/features/group/services/group-service';
|
} from "@/features/group/services/group-service";
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from "@mantine/notifications";
|
||||||
import { QueryParams } from '@/lib/types.ts';
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
|
||||||
export function useGetGroupsQuery(
|
export function useGetGroupsQuery(
|
||||||
params?: QueryParams
|
params?: QueryParams,
|
||||||
): UseQueryResult<any, Error> {
|
): UseQueryResult<IPagination<IGroup>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['groups', params],
|
queryKey: ["groups", params],
|
||||||
queryFn: () => getGroups(params),
|
queryFn: () => getGroups(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['group', groupId],
|
queryKey: ["group", groupId],
|
||||||
queryFn: () => getGroupById(groupId),
|
queryFn: () => getGroupById(groupId),
|
||||||
enabled: !!groupId,
|
enabled: !!groupId,
|
||||||
});
|
});
|
||||||
@@ -42,13 +45,13 @@ export function useCreateGroupMutation() {
|
|||||||
mutationFn: (data) => createGroup(data),
|
mutationFn: (data) => createGroup(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['groups'],
|
queryKey: ["groups"],
|
||||||
});
|
});
|
||||||
|
|
||||||
notifications.show({ message: 'Group created successfully' });
|
notifications.show({ message: "Group created successfully" });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
notifications.show({ message: 'Failed to create group', color: 'red' });
|
notifications.show({ message: "Failed to create group", color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -59,14 +62,14 @@ export function useUpdateGroupMutation() {
|
|||||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||||
mutationFn: (data) => updateGroup(data),
|
mutationFn: (data) => updateGroup(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Group updated successfully' });
|
notifications.show({ message: "Group updated successfully" });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['group', variables.groupId],
|
queryKey: ["group", variables.groupId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -77,28 +80,25 @@ export function useDeleteGroupMutation() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Group deleted successfully' });
|
notifications.show({ message: "Group deleted successfully" });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["groups"] });
|
||||||
const groups = queryClient.getQueryData(['groups']) as any;
|
|
||||||
if (groups) {
|
|
||||||
groups.items = groups.items?.filter(
|
|
||||||
(group: IGroup) => group.id !== variables
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(['groups'], groups);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGroupMembersQuery(groupId: string) {
|
export function useGroupMembersQuery(
|
||||||
|
groupId: string,
|
||||||
|
params?: QueryParams,
|
||||||
|
): UseQueryResult<IPagination<IUser>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['groupMembers', groupId],
|
queryKey: ["groupMembers", groupId, params],
|
||||||
queryFn: () => getGroupMembers(groupId),
|
queryFn: () => getGroupMembers(groupId, params),
|
||||||
enabled: !!groupId,
|
enabled: !!groupId,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +108,15 @@ export function useAddGroupMemberMutation() {
|
|||||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||||
mutationFn: (data) => addGroupMember(data),
|
mutationFn: (data) => addGroupMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Added successfully' });
|
notifications.show({ message: "Added successfully" });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['groupMembers', variables.groupId],
|
queryKey: ["groupMembers", variables.groupId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: 'Failed to add group members',
|
message: "Failed to add group members",
|
||||||
color: 'red',
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -135,14 +135,14 @@ export function useRemoveGroupMemberMutation() {
|
|||||||
>({
|
>({
|
||||||
mutationFn: (data) => removeGroupMember(data),
|
mutationFn: (data) => removeGroupMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Removed successfully' });
|
notifications.show({ message: "Removed successfully" });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['groupMembers', variables.groupId],
|
queryKey: ["groupMembers", variables.groupId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { IGroup } from "@/features/group/types/group.types";
|
import { IGroup } from "@/features/group/types/group.types";
|
||||||
import { QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
|
||||||
export async function getGroups(params?: QueryParams): Promise<any> {
|
export async function getGroups(
|
||||||
// TODO: returns paginated. Fix type
|
params?: QueryParams,
|
||||||
const req = await api.post<any>("/groups", params);
|
): Promise<IPagination<IGroup>> {
|
||||||
|
const req = await api.post("/groups", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +29,11 @@ export async function deleteGroup(data: { groupId: string }): Promise<void> {
|
|||||||
await api.post("/groups/delete", data);
|
await api.post("/groups/delete", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupMembers(groupId: string) {
|
export async function getGroupMembers(
|
||||||
const req = await api.post("/groups/members", { groupId });
|
groupId: string,
|
||||||
|
params?: QueryParams,
|
||||||
|
): Promise<IPagination<IUser>> {
|
||||||
|
const req = await api.post("/groups/members", { groupId, params });
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function useSearchSuggestionsQuery(
|
|||||||
params: SearchSuggestionParams,
|
params: SearchSuggestionParams,
|
||||||
): UseQueryResult<ISuggestionResult, Error> {
|
): UseQueryResult<ISuggestionResult, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["search-suggestion", params],
|
queryKey: ["search-suggestion", params.query],
|
||||||
|
staleTime: 60 * 1000, // 1min
|
||||||
queryFn: () => searchSuggestions(params),
|
queryFn: () => searchSuggestions(params),
|
||||||
enabled: !!params.query,
|
enabled: !!params.query,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Group, Center, Text } from "@mantine/core";
|
import { Group, Center, Text } from "@mantine/core";
|
||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
import { getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
@@ -33,13 +34,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" w="100%">
|
<Group wrap="nowrap" w="100%">
|
||||||
<Center>
|
<Center>{getPageIcon(page?.icon)}</Center>
|
||||||
{page?.icon ? (
|
|
||||||
<span style={{ fontSize: "20px" }}>{page.icon}</span>
|
|
||||||
) : (
|
|
||||||
<IconFileDescription size={20} />
|
|
||||||
)}
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text>{page.title}</Text>
|
<Text>{page.title}</Text>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
export interface IPageSearch {
|
export interface IPageSearch {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,11 +21,15 @@ export interface SearchSuggestionParams {
|
|||||||
query: string;
|
query: string;
|
||||||
includeUsers?: boolean;
|
includeUsers?: boolean;
|
||||||
includeGroups?: boolean;
|
includeGroups?: boolean;
|
||||||
|
includePages?: boolean;
|
||||||
|
spaceId?: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISuggestionResult {
|
export interface ISuggestionResult {
|
||||||
users?: Partial<IUser[]>;
|
users?: Partial<IUser[]>;
|
||||||
groups?: Partial<IGroup[]>;
|
groups?: Partial<IGroup[]>;
|
||||||
|
pages?: Partial<IPage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageSearchParams {
|
export interface IPageSearchParams {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface MultiMemberSelectProps {
|
|||||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||||
option,
|
option,
|
||||||
}) => (
|
}) => (
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{option["type"] === "user" && (
|
{option["type"] === "user" && (
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={option["avatarUrl"]}
|
avatarUrl={option["avatarUrl"]}
|
||||||
@@ -25,7 +25,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
)}
|
)}
|
||||||
{option["type"] === "group" && <IconGroupCircle />}
|
{option["type"] === "group" && <IconGroupCircle />}
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{option.label}</Text>
|
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ interface SpaceSelectProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{option.label}</Text>
|
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SwitchSpaceProps {
|
|||||||
|
|
||||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -27,6 +28,8 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
position="bottom"
|
position="bottom"
|
||||||
withArrow
|
withArrow
|
||||||
shadow="md"
|
shadow="md"
|
||||||
|
opened={opened}
|
||||||
|
onChange={toggle}
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Button
|
||||||
@@ -35,6 +38,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
justify="space-between"
|
justify="space-between"
|
||||||
rightSection={<IconChevronDown size={18} />}
|
rightSection={<IconChevronDown size={18} />}
|
||||||
color="gray"
|
color="gray"
|
||||||
|
onClick={open}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery();
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading } = useGetSpacesQuery({ page });
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||||
|
|
||||||
@@ -19,50 +21,57 @@ export default function SpaceList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data && (
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table.ScrollContainer minWidth={400}>
|
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
<Table.Thead>
|
||||||
<Table.Thead>
|
<Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Th>{t("Space")}</Table.Th>
|
||||||
<Table.Th>{t("Space")}</Table.Th>
|
<Table.Th>{t("Members")}</Table.Th>
|
||||||
<Table.Th>{t("Members")}</Table.Th>
|
</Table.Tr>
|
||||||
</Table.Tr>
|
</Table.Thead>
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((space, index) => (
|
{data?.items.map((space, index) => (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={index}
|
key={index}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => handleClick(space.id)}
|
onClick={() => handleClick(space.id)}
|
||||||
>
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{formatMemberCount(space.memberCount, t)}
|
{formatMemberCount(space.memberCount, t)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
|
{data?.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedSpaceId && (
|
{selectedSpaceId && (
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
} from "@/features/space/types/space-role-data.ts";
|
} from "@/features/space/types/space-role-data.ts";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
|
|
||||||
@@ -30,8 +33,12 @@ export default function SpaceMembersList({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceMembersProps) {
|
}: SpaceMembersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId);
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
|
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
||||||
|
page,
|
||||||
|
limit: 100,
|
||||||
|
query: search,
|
||||||
|
});
|
||||||
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
||||||
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
||||||
|
|
||||||
@@ -98,94 +105,102 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data && (
|
<SearchInput onSearch={handleSearch} />
|
||||||
<Table.ScrollContainer minWidth={500}>
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table verticalSpacing={8}>
|
<Table highlightOnHover verticalSpacing={8}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("Member")}</Table.Th>
|
<Table.Th>{t("Member")}</Table.Th>
|
||||||
<Table.Th>{t("Role")}</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((member, index) => (
|
{data?.items.map((member, index) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{member.type === "user" && (
|
{member.type === "user" && (
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={member?.avatarUrl}
|
avatarUrl={member?.avatarUrl}
|
||||||
name={member.name}
|
name={member.name}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{member.type === "group" && <IconGroupCircle />}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{member?.name}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
{member.type == "user" && member?.email}
|
|
||||||
|
|
||||||
{member.type == "group" &&
|
|
||||||
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<RoleSelectMenu
|
|
||||||
roles={spaceRoleData}
|
|
||||||
roleName={getSpaceRoleLabel(member.role)}
|
|
||||||
onChange={(newRole) =>
|
|
||||||
handleRoleChange(
|
|
||||||
member.id,
|
|
||||||
member.type,
|
|
||||||
newRole,
|
|
||||||
member.role,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
{!readOnly && (
|
|
||||||
<Menu
|
|
||||||
shadow="xl"
|
|
||||||
position="bottom-end"
|
|
||||||
offset={20}
|
|
||||||
width={200}
|
|
||||||
withArrow
|
|
||||||
arrowPosition="center"
|
|
||||||
>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" c="gray">
|
|
||||||
<IconDots size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() =>
|
|
||||||
openRemoveModal(member.id, member.type)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Remove space member")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
{member.type === "group" && <IconGroupCircle />}
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
<div>
|
||||||
</Table>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
</Table.ScrollContainer>
|
{member?.name}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
|
{member.type == "group" &&
|
||||||
|
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<RoleSelectMenu
|
||||||
|
roles={spaceRoleData}
|
||||||
|
roleName={getSpaceRoleLabel(member.role)}
|
||||||
|
onChange={(newRole) =>
|
||||||
|
handleRoleChange(
|
||||||
|
member.id,
|
||||||
|
member.type,
|
||||||
|
newRole,
|
||||||
|
member.role,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
{!readOnly && (
|
||||||
|
<Menu
|
||||||
|
shadow="xl"
|
||||||
|
position="bottom-end"
|
||||||
|
offset={20}
|
||||||
|
width={200}
|
||||||
|
withArrow
|
||||||
|
arrowPosition="center"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" c="gray">
|
||||||
|
<IconDots size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() =>
|
||||||
|
openRemoveModal(member.id, member.type)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Remove space member")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
|
{data?.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
keepPreviousData,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from '@tanstack/react-query';
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
IAddSpaceMember,
|
IAddSpaceMember,
|
||||||
IChangeSpaceMemberRole,
|
IChangeSpaceMemberRole,
|
||||||
IRemoveSpaceMember,
|
IRemoveSpaceMember,
|
||||||
ISpace,
|
ISpace,
|
||||||
ISpaceMember,
|
ISpaceMember,
|
||||||
} from '@/features/space/types/space.types';
|
} from "@/features/space/types/space.types";
|
||||||
import {
|
import {
|
||||||
addSpaceMember,
|
addSpaceMember,
|
||||||
changeMemberRole,
|
changeMemberRole,
|
||||||
@@ -21,22 +22,23 @@ import {
|
|||||||
createSpace,
|
createSpace,
|
||||||
updateSpace,
|
updateSpace,
|
||||||
deleteSpace,
|
deleteSpace,
|
||||||
} from '@/features/space/services/space-service.ts';
|
} from "@/features/space/services/space-service.ts";
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from '@/lib/types.ts';
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
|
||||||
export function useGetSpacesQuery(
|
export function useGetSpacesQuery(
|
||||||
params?: QueryParams
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['spaces', params],
|
queryKey: ["spaces", params],
|
||||||
queryFn: () => getSpaces(params),
|
queryFn: () => getSpaces(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['space', spaceId],
|
queryKey: ["space", spaceId],
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
@@ -50,22 +52,22 @@ export function useCreateSpaceMutation() {
|
|||||||
mutationFn: (data) => createSpace(data),
|
mutationFn: (data) => createSpace(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['spaces'],
|
queryKey: ["spaces"],
|
||||||
});
|
});
|
||||||
notifications.show({ message: 'Space created successfully' });
|
notifications.show({ message: "Space created successfully" });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetSpaceBySlugQuery(
|
export function useGetSpaceBySlugQuery(
|
||||||
spaceId: string
|
spaceId: string,
|
||||||
): UseQueryResult<ISpace, Error> {
|
): UseQueryResult<ISpace, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['space', spaceId],
|
queryKey: ["space", spaceId],
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
@@ -78,25 +80,25 @@ export function useUpdateSpaceMutation() {
|
|||||||
return useMutation<ISpace, Error, Partial<ISpace>>({
|
return useMutation<ISpace, Error, Partial<ISpace>>({
|
||||||
mutationFn: (data) => updateSpace(data),
|
mutationFn: (data) => updateSpace(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Space updated successfully' });
|
notifications.show({ message: "Space updated successfully" });
|
||||||
|
|
||||||
const space = queryClient.getQueryData([
|
const space = queryClient.getQueryData([
|
||||||
'space',
|
"space",
|
||||||
variables.spaceId,
|
variables.spaceId,
|
||||||
]) as ISpace;
|
]) as ISpace;
|
||||||
if (space) {
|
if (space) {
|
||||||
const updatedSpace = { ...space, ...data };
|
const updatedSpace = { ...space, ...data };
|
||||||
queryClient.setQueryData(['space', variables.spaceId], updatedSpace);
|
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||||
queryClient.setQueryData(['space', data.slug], updatedSpace);
|
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['spaces'],
|
queryKey: ["spaces"],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -107,37 +109,39 @@ export function useDeleteSpaceMutation() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id),
|
mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Space deleted successfully' });
|
notifications.show({ message: "Space deleted successfully" });
|
||||||
|
|
||||||
if (variables.slug) {
|
if (variables.slug) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ['space', variables.slug],
|
queryKey: ["space", variables.slug],
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaces = queryClient.getQueryData(['spaces']) as any;
|
const spaces = queryClient.getQueryData(["spaces"]) as any;
|
||||||
if (spaces) {
|
if (spaces) {
|
||||||
spaces.items = spaces.items?.filter(
|
spaces.items = spaces.items?.filter(
|
||||||
(space: ISpace) => space.id !== variables.id
|
(space: ISpace) => space.id !== variables.id,
|
||||||
);
|
);
|
||||||
queryClient.setQueryData(['spaces'], spaces);
|
queryClient.setQueryData(["spaces"], spaces);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpaceMembersQuery(
|
export function useSpaceMembersQuery(
|
||||||
spaceId: string
|
spaceId: string,
|
||||||
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISpaceMember>, Error> {
|
): UseQueryResult<IPagination<ISpaceMember>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['spaceMembers', spaceId],
|
queryKey: ["spaceMembers", spaceId, params],
|
||||||
queryFn: () => getSpaceMembers(spaceId),
|
queryFn: () => getSpaceMembers(spaceId, params),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +151,14 @@ export function useAddSpaceMemberMutation() {
|
|||||||
return useMutation<void, Error, IAddSpaceMember>({
|
return useMutation<void, Error, IAddSpaceMember>({
|
||||||
mutationFn: (data) => addSpaceMember(data),
|
mutationFn: (data) => addSpaceMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Members added successfully' });
|
notifications.show({ message: "Members added successfully" });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['spaceMembers', variables.spaceId],
|
queryKey: ["spaceMembers", variables.spaceId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,14 +169,14 @@ export function useRemoveSpaceMemberMutation() {
|
|||||||
return useMutation<void, Error, IRemoveSpaceMember>({
|
return useMutation<void, Error, IRemoveSpaceMember>({
|
||||||
mutationFn: (data) => removeSpaceMember(data),
|
mutationFn: (data) => removeSpaceMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Removed successfully' });
|
notifications.show({ message: "Removed successfully" });
|
||||||
queryClient.refetchQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['spaceMembers', variables.spaceId],
|
queryKey: ["spaceMembers", variables.spaceId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,15 +187,15 @@ export function useChangeSpaceMemberRoleMutation() {
|
|||||||
return useMutation<void, Error, IChangeSpaceMemberRole>({
|
return useMutation<void, Error, IChangeSpaceMemberRole>({
|
||||||
mutationFn: (data) => changeMemberRole(data),
|
mutationFn: (data) => changeMemberRole(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: 'Member role updated successfully' });
|
notifications.show({ message: "Member role updated successfully" });
|
||||||
// due to pagination levels, change in cache instead
|
// due to pagination levels, change in cache instead
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ['spaceMembers', variables.spaceId],
|
queryKey: ["spaceMembers", variables.spaceId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error['response']?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({ message: errorMessage, color: 'red' });
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import {
|
|||||||
IExportSpaceParams,
|
IExportSpaceParams,
|
||||||
IRemoveSpaceMember,
|
IRemoveSpaceMember,
|
||||||
ISpace,
|
ISpace,
|
||||||
|
ISpaceMember,
|
||||||
} from "@/features/space/types/space.types";
|
} from "@/features/space/types/space.types";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
export async function getSpaces(
|
export async function getSpaces(
|
||||||
params?: QueryParams
|
params?: QueryParams,
|
||||||
): Promise<IPagination<ISpace>> {
|
): Promise<IPagination<ISpace>> {
|
||||||
const req = await api.post("/spaces", params);
|
const req = await api.post("/spaces", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
@@ -37,9 +38,10 @@ export async function deleteSpace(spaceId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSpaceMembers(
|
export async function getSpaceMembers(
|
||||||
spaceId: string
|
spaceId: string,
|
||||||
): Promise<IPagination<IUser>> {
|
params?: QueryParams,
|
||||||
const req = await api.post<any>("/spaces/members", { spaceId });
|
): Promise<IPagination<ISpaceMember>> {
|
||||||
|
const req = await api.post<any>("/spaces/members", { spaceId, ...params });
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +50,13 @@ export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSpaceMember(
|
export async function removeSpaceMember(
|
||||||
data: IRemoveSpaceMember
|
data: IRemoveSpaceMember,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.post("/spaces/members/remove", data);
|
await api.post("/spaces/members/remove", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeMemberRole(
|
export async function changeMemberRole(
|
||||||
data: IChangeSpaceMemberRole
|
data: IChangeSpaceMemberRole,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.post("/spaces/members/change-role", data);
|
await api.post("/spaces/members/change-role", data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,18 @@ function LanguageSwitcher() {
|
|||||||
<Select
|
<Select
|
||||||
label={t("Select language")}
|
label={t("Select language")}
|
||||||
data={[
|
data={[
|
||||||
{ value: "en-US", label: "English (United States)" },
|
{ value: "en-US", label: "English (US)" },
|
||||||
{ value: "de-DE", label: "Deutsch (Germany)" },
|
{ value: "de-DE", label: "Deutsch (German)" },
|
||||||
{ value: "fr-FR", label: "Français (France)" },
|
{ value: "fr-FR", label: "Français (French)" },
|
||||||
{ value: "pt-BR", label: "Português (Brazilian)" },
|
{ value: "es-ES", label: "Español (Spanish)" },
|
||||||
|
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||||
|
{ value: "it-IT", label: "Italiano (Italian)" },
|
||||||
|
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
||||||
|
{ value: "ko-KR", label: "한국어 (Korean)" },
|
||||||
|
{ value: "ru-RU", label: "Русский (Russian)" },
|
||||||
{ value: "zh-CN", label: "中文 (简体)" },
|
{ value: "zh-CN", label: "中文 (简体)" },
|
||||||
]}
|
]}
|
||||||
value={language}
|
value={language || 'en-US'}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
|
|||||||
@@ -3,11 +3,42 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
import { SOCKET_URL } from "@/features/websocket/types";
|
||||||
|
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
||||||
|
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
const { data, isLoading, error } = useCurrentUser();
|
const { data, isLoading, error } = useCurrentUser();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
const [, setSocket] = useAtom(socketAtom);
|
||||||
|
// fetch collab token on load
|
||||||
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newSocket = io(SOCKET_URL, {
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
setSocket(newSocket);
|
||||||
|
|
||||||
|
newSocket.on("connect", () => {
|
||||||
|
console.log("ws connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("ws disconnected");
|
||||||
|
newSocket.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useQuerySubscription();
|
||||||
|
useTreeSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data.user && data.workspace) {
|
if (data && data.user && data.workspace) {
|
||||||
|
|||||||
+50
-44
@@ -1,19 +1,22 @@
|
|||||||
import {Group, Table, Avatar, Text, Alert} from "@mantine/core";
|
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||||
import {useWorkspaceInvitationsQuery} from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import {getUserRoleLabel} from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
import {IconInfoCircle} from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
import {formattedDate, timeAgo} from "@/lib/time.ts";
|
import { timeAgo } from "@/lib/time.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
const {isAdmin} = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -23,47 +26,50 @@ export default function WorkspaceInvitesTable() {
|
|||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{data && (
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<>
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<Table.ScrollContainer minWidth={500}>
|
<Table.Thead>
|
||||||
<Table verticalSpacing="sm">
|
<Table.Tr>
|
||||||
<Table.Thead>
|
<Table.Th>{t("Email")}</Table.Th>
|
||||||
<Table.Tr>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
<Table.Th>{t("Email")}</Table.Th>
|
<Table.Th>{t("Date")}</Table.Th>
|
||||||
<Table.Th>{t("Role")}</Table.Th>
|
</Table.Tr>
|
||||||
<Table.Th>{t("Date")}</Table.Th>
|
</Table.Thead>
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((invitation, index) => (
|
{data?.items.map((invitation, index) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar name={invitation.email} color="initials"/>
|
<Avatar name={invitation.email} color="initials" />
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500}>
|
<Text fz="sm" fw={500}>
|
||||||
{invitation.email}
|
{invitation.email}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>{t(getUserRoleLabel(invitation.role))}</Table.Td>
|
<Table.Td>{t(getUserRoleLabel(invitation.role))}</Table.Td>
|
||||||
|
|
||||||
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
|
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{isAdmin && (
|
{isAdmin && <InviteActionMenu invitationId={invitation.id} />}
|
||||||
<InviteActionMenu invitationId={invitation.id}/>
|
</Table.Td>
|
||||||
)}
|
</Table.Tr>
|
||||||
</Table.Td>
|
))}
|
||||||
</Table.Tr>
|
</Table.Tbody>
|
||||||
))}
|
</Table>
|
||||||
</Table.Tbody>
|
</Table.ScrollContainer>
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
{data?.items.length > 0 && (
|
||||||
</>
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+48
-24
@@ -1,10 +1,10 @@
|
|||||||
import {Group, Table, Text, Badge} from "@mantine/core";
|
import { Group, Table, Text, Badge } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
useChangeMemberRoleMutation,
|
useChangeMemberRoleMutation,
|
||||||
useWorkspaceMembersQuery,
|
useWorkspaceMembersQuery,
|
||||||
} from "@/features/workspace/queries/workspace-query.ts";
|
} from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import React from "react";
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
|
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
|
||||||
import {
|
import {
|
||||||
getUserRoleLabel,
|
getUserRoleLabel,
|
||||||
@@ -13,12 +13,21 @@ import {
|
|||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { UserRole } from "@/lib/types.ts";
|
import { UserRole } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
|
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||||
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
|
const { data, isLoading } = useWorkspaceMembersQuery({
|
||||||
|
page,
|
||||||
|
limit: 100,
|
||||||
|
query: search,
|
||||||
|
});
|
||||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||||
const {isAdmin, isOwner} = useUserRole();
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
|
||||||
const assignableUserRoles = isOwner
|
const assignableUserRoles = isOwner
|
||||||
? userRoleData
|
? userRoleData
|
||||||
@@ -43,25 +52,29 @@ export default function WorkspaceMembersTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data && (
|
<SearchInput onSearch={handleSearch} />
|
||||||
<Table.ScrollContainer minWidth={500}>
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table verticalSpacing="sm">
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("User")}</Table.Th>
|
<Table.Th>{t("User")}</Table.Th>
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
<Table.Th>{t("Role")}</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items.map((user, index) => (
|
{data?.items.length > 0 ? (
|
||||||
|
data?.items.map((user, index) => (
|
||||||
<Table.Tr key={index}>
|
<Table.Tr key={index}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
|
<CustomAvatar
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
name={user.name}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{user.name}
|
{user.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
@@ -84,10 +97,21 @@ export default function WorkspaceMembersTable() {
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))
|
||||||
</Table.Tbody>
|
) : (
|
||||||
</Table>
|
<NoTableResults colSpan={3} />
|
||||||
</Table.ScrollContainer>
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
|
{data?.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
keepPreviousData,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
IInvitation,
|
IInvitation,
|
||||||
IWorkspace,
|
IWorkspace,
|
||||||
} from "@/features/workspace/types/workspace.types.ts";
|
} from "@/features/workspace/types/workspace.types.ts";
|
||||||
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
|
||||||
export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
|
export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -40,10 +42,13 @@ export function useWorkspacePublicDataQuery(): UseQueryResult<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceMembersQuery(params?: QueryParams) {
|
export function useWorkspaceMembersQuery(
|
||||||
|
params?: QueryParams,
|
||||||
|
): UseQueryResult<IPagination<IUser>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["workspaceMembers", params],
|
queryKey: ["workspaceMembers", params],
|
||||||
queryFn: () => getWorkspaceMembers(params),
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +58,6 @@ export function useChangeMemberRoleMutation() {
|
|||||||
return useMutation<any, Error, any>({
|
return useMutation<any, Error, any>({
|
||||||
mutationFn: (data) => changeMemberRole(data),
|
mutationFn: (data) => changeMemberRole(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
// TODO: change in cache instead
|
|
||||||
notifications.show({ message: "Member role updated successfully" });
|
notifications.show({ message: "Member role updated successfully" });
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ["workspaceMembers"],
|
queryKey: ["workspaceMembers"],
|
||||||
@@ -72,6 +76,7 @@ export function useWorkspaceInvitationsQuery(
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["invitations", params],
|
queryKey: ["invitations", params],
|
||||||
queryFn: () => getPendingInvitations(params),
|
queryFn: () => getPendingInvitations(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +87,6 @@ export function useCreateInvitationMutation() {
|
|||||||
mutationFn: (data) => createInvitation(data),
|
mutationFn: (data) => createInvitation(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Invitation sent" });
|
notifications.show({ message: "Invitation sent" });
|
||||||
// TODO: mutate cache
|
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ["invitations"],
|
queryKey: ["invitations"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
IAcceptInvite,
|
IAcceptInvite,
|
||||||
} from "../types/workspace.types";
|
} from "../types/workspace.types";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { ITokenResponse } from "@/features/auth/types/auth.types.ts";
|
|
||||||
|
|
||||||
export async function getWorkspace(): Promise<IWorkspace> {
|
export async function getWorkspace(): Promise<IWorkspace> {
|
||||||
const req = await api.post<IWorkspace>("/workspace/info");
|
const req = await api.post<IWorkspace>("/workspace/info");
|
||||||
@@ -19,7 +18,6 @@ export async function getWorkspacePublicData(): Promise<IWorkspace> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: fix all paginated types
|
|
||||||
export async function getWorkspaceMembers(
|
export async function getWorkspaceMembers(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): Promise<IPagination<IUser>> {
|
): Promise<IPagination<IUser>> {
|
||||||
@@ -51,11 +49,8 @@ export async function createInvitation(data: ICreateInvite) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptInvitation(
|
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
|
||||||
data: IAcceptInvite,
|
await api.post<void>("/workspace/invites/accept", data);
|
||||||
): Promise<ITokenResponse> {
|
|
||||||
const req = await api.post("/workspace/invites/accept", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resendInvitation(data: {
|
export async function resendInvitation(data: {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
export function usePaginateAndSearch(initialQuery: string = "") {
|
||||||
|
const [search, setSearch] = useState(initialQuery);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const prevSearchRef = useRef(search);
|
||||||
|
|
||||||
|
const handleSearch = useCallback((newQuery: string) => {
|
||||||
|
if (prevSearchRef.current !== newQuery) {
|
||||||
|
prevSearchRef.current = newQuery;
|
||||||
|
setSearch(newQuery);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { search, page, setPage, handleSearch };
|
||||||
|
}
|
||||||
@@ -1,34 +1,11 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import Cookies from "js-cookie";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import Routes from "@/lib/app-route.ts";
|
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const tokenData = Cookies.get("authTokens");
|
|
||||||
|
|
||||||
let accessToken: string;
|
|
||||||
try {
|
|
||||||
accessToken = tokenData && JSON.parse(tokenData)?.accessToken;
|
|
||||||
} catch (err) {
|
|
||||||
console.log("invalid authTokens:", err.message);
|
|
||||||
Cookies.remove("authTokens");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessToken) {
|
|
||||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// we need the response headers for these endpoints
|
// we need the response headers for these endpoints
|
||||||
@@ -45,11 +22,14 @@ api.interceptors.response.use(
|
|||||||
(error) => {
|
(error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401: {
|
||||||
|
const url = new URL(error.request.responseURL)?.pathname;
|
||||||
|
if (url === "/api/auth/collab-token") return;
|
||||||
|
|
||||||
// Handle unauthorized error
|
// Handle unauthorized error
|
||||||
Cookies.remove("authTokens");
|
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 403:
|
case 403:
|
||||||
// Handle forbidden error
|
// Handle forbidden error
|
||||||
break;
|
break;
|
||||||
@@ -61,10 +41,8 @@ api.interceptors.response.use(
|
|||||||
.includes("workspace not found")
|
.includes("workspace not found")
|
||||||
) {
|
) {
|
||||||
console.log("workspace not found");
|
console.log("workspace not found");
|
||||||
Cookies.remove("authTokens");
|
if (window.location.pathname != APP_ROUTE.AUTH.SETUP) {
|
||||||
|
window.location.href = APP_ROUTE.AUTH.SETUP;
|
||||||
if (window.location.pathname != Routes.AUTH.SETUP) {
|
|
||||||
window.location.href = Routes.AUTH.SETUP;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -76,15 +54,19 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
if (
|
const exemptPaths = [
|
||||||
window.location.pathname != Routes.AUTH.LOGIN &&
|
APP_ROUTE.AUTH.LOGIN,
|
||||||
window.location.pathname != Routes.AUTH.SIGNUP
|
APP_ROUTE.AUTH.SIGNUP,
|
||||||
) {
|
APP_ROUTE.AUTH.FORGOT_PASSWORD,
|
||||||
window.location.href = Routes.AUTH.LOGIN;
|
APP_ROUTE.AUTH.PASSWORD_RESET,
|
||||||
|
"/invites",
|
||||||
|
];
|
||||||
|
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
||||||
|
window.location.href = APP_ROUTE.AUTH.LOGIN;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +1,60 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
CONFIG?: Record<string, string>;
|
CONFIG?: Record<string, string>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAppName(): string{
|
export function getAppName(): string {
|
||||||
return 'Docmost';
|
return "Docmost";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAppUrl(): string {
|
export function getAppUrl(): string {
|
||||||
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
|
return `${window.location.protocol}//${window.location.host}`;
|
||||||
|
|
||||||
// if (import.meta.env.DEV) {
|
|
||||||
// return appUrl || "http://localhost:3000";
|
|
||||||
//}
|
|
||||||
|
|
||||||
return `${window.location.protocol}//${window.location.host}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBackendUrl(): string {
|
export function getBackendUrl(): string {
|
||||||
return getAppUrl() + '/api';
|
return getAppUrl() + "/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCollaborationUrl(): string {
|
export function getCollaborationUrl(): string {
|
||||||
const COLLAB_PATH = '/collab';
|
const baseUrl =
|
||||||
|
getConfigValue("COLLAB_URL") ||
|
||||||
|
(import.meta.env.DEV ? process.env.APP_URL : getAppUrl());
|
||||||
|
|
||||||
let url = getAppUrl();
|
const collabUrl = new URL("/collab", baseUrl);
|
||||||
if (import.meta.env.DEV) {
|
collabUrl.protocol = collabUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||||
url = process.env.APP_URL;
|
return collabUrl.toString();
|
||||||
}
|
|
||||||
|
|
||||||
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
|
|
||||||
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(avatarUrl: string) {
|
export function getAvatarUrl(avatarUrl: string) {
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) return null;
|
||||||
return null;
|
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
||||||
}
|
|
||||||
|
|
||||||
if (avatarUrl?.startsWith('http')) {
|
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
||||||
return avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSpaceUrl(spaceSlug: string) {
|
export function getSpaceUrl(spaceSlug: string) {
|
||||||
return '/s/' + spaceSlug;
|
return "/s/" + spaceSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileUrl(src: string) {
|
export function getFileUrl(src: string) {
|
||||||
return src?.startsWith('/files/') ? getBackendUrl() + src : src;
|
return src?.startsWith("/files/") ? getBackendUrl() + src : src;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileUploadSizeLimit() {
|
export function getFileUploadSizeLimit() {
|
||||||
const limit =getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
|
const limit = getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
|
||||||
return bytes(limit);
|
return bytes(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDrawioUrl() {
|
export function getDrawioUrl() {
|
||||||
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigValue(key: string, defaultValue: string = undefined) {
|
function getConfigValue(key: string, defaultValue: string = undefined): string {
|
||||||
return window.CONFIG?.[key] || process?.env?.[key] || defaultValue;
|
const rawValue = import.meta.env.DEV
|
||||||
}
|
? process?.env?.[key]
|
||||||
|
: window?.CONFIG?.[key];
|
||||||
|
return rawValue ?? defaultValue;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const INTERNAL_LINK_REGEX =
|
||||||
|
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { validate as isValidUUID } from "uuid";
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
export function formatMemberCount(memberCount: number, t: TFunction): string {
|
export function formatMemberCount(memberCount: number, t: TFunction): string {
|
||||||
@@ -8,12 +12,15 @@ export function formatMemberCount(memberCount: number, t: TFunction): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractPageSlugId(input: string): string {
|
export function extractPageSlugId(slug: string): string {
|
||||||
if (!input) {
|
if (!slug) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const parts = input.split("-");
|
if (isValidUUID(slug)) {
|
||||||
return parts.length > 1 ? parts[parts.length - 1] : input;
|
return slug;
|
||||||
|
}
|
||||||
|
const parts = slug.split("-");
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1] : slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const computeSpaceSlug = (name: string) => {
|
export const computeSpaceSlug = (name: string) => {
|
||||||
@@ -76,3 +83,13 @@ export function decodeBase64ToSvgString(base64Data: string): string {
|
|||||||
export function capitalizeFirstChar(string: string) {
|
export function capitalizeFirstChar(string: string) {
|
||||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPageIcon(icon: string, size = 18): string | ReactNode {
|
||||||
|
return (
|
||||||
|
icon || (
|
||||||
|
<ActionIcon variant="transparent" color="gray" size={size}>
|
||||||
|
<IconFileDescription size={size} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import { Link, useSearchParams } from "react-router-dom";
|
|||||||
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
|
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
|
||||||
import { Button, Container, Group, Text } from "@mantine/core";
|
import { Button, Container, Group, Text } from "@mantine/core";
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function PasswordReset() {
|
export default function PasswordReset() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { data, isLoading, isError } = useVerifyUserTokenQuery({
|
const { data, isLoading, isError } = useVerifyUserTokenQuery({
|
||||||
token: searchParams.get("token"),
|
token: searchParams.get("token"),
|
||||||
@@ -22,11 +24,13 @@ export default function PasswordReset() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>Password Reset - {getAppName()}</title>
|
<title>
|
||||||
|
{t("Password Reset")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Container my={40}>
|
<Container my={40}>
|
||||||
<Text size="lg" ta="center">
|
<Text size="lg" ta="center">
|
||||||
Invalid or expired password reset link
|
{t("Invalid or expired password reset link")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
<Button
|
<Button
|
||||||
@@ -35,7 +39,7 @@ export default function PasswordReset() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
Goto login page
|
{t("Goto login page")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -46,7 +50,9 @@ export default function PasswordReset() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>Password Reset - {getAppName()}</title>
|
<title>
|
||||||
|
{t("Password Reset")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<PasswordResetForm resetToken={resetToken} />
|
<PasswordResetForm resetToken={resetToken} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Helmet } from "react-helmet-async";
|
|||||||
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
@@ -21,6 +20,7 @@ export default function Page() {
|
|||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
error,
|
||||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
@@ -32,7 +32,9 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !page) {
|
if (isError || !page) {
|
||||||
// TODO: fix this
|
if ([401, 403, 404].includes(error?.["status"])) {
|
||||||
|
return <div>{t("Page not found")}</div>;
|
||||||
|
}
|
||||||
return <div>{t("Error fetching page data.")}</div>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,21 @@ import * as path from "path";
|
|||||||
export const envPath = path.resolve(process.cwd(), "..", "..");
|
export const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL } = loadEnv(mode, envPath, "");
|
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL, COLLAB_URL } = loadEnv(
|
||||||
|
mode,
|
||||||
|
envPath,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
define: {
|
define: {
|
||||||
"process.env": {
|
"process.env": {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
DRAWIO_URL
|
DRAWIO_URL,
|
||||||
|
COLLAB_URL,
|
||||||
},
|
},
|
||||||
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
|
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
+46
-43
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.7.0",
|
"version": "0.8.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"start:dev": "cross-env NODE_ENV=development nest start --watch",
|
"start:dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
|
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
|
||||||
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
||||||
|
"collab:prod": "cross-env NODE_ENV=production node dist/collaboration/server/collab-main",
|
||||||
"email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails",
|
"email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails",
|
||||||
"migration:create": "tsx src/database/migrate.ts create",
|
"migration:create": "tsx src/database/migrate.ts create",
|
||||||
"migration:up": "tsx src/database/migrate.ts up",
|
"migration:up": "tsx src/database/migrate.ts up",
|
||||||
@@ -28,46 +29,48 @@
|
|||||||
"test:e2e": "jest --config test/jest-e2e.json"
|
"test:e2e": "jest --config test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.701.0",
|
"@aws-sdk/client-s3": "3.701.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.701.0",
|
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||||
"@casl/ability": "^6.7.2",
|
"@casl/ability": "^6.7.3",
|
||||||
"@fastify/cookie": "^9.4.0",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^8.3.0",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^8.1.1",
|
||||||
"@nestjs/bullmq": "^10.2.2",
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^10.4.9",
|
"@nestjs/common": "^11.0.10",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^10.4.9",
|
"@nestjs/core": "^11.0.10",
|
||||||
"@nestjs/event-emitter": "^2.1.1",
|
"@nestjs/event-emitter": "^3.0.0",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/mapped-types": "^2.0.6",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^10.4.9",
|
"@nestjs/platform-fastify": "^11.0.10",
|
||||||
"@nestjs/platform-socket.io": "^10.4.9",
|
"@nestjs/platform-socket.io": "^11.0.10",
|
||||||
"@nestjs/terminus": "^10.2.3",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^10.4.9",
|
"@nestjs/websockets": "^11.0.10",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "0.0.28",
|
||||||
"@react-email/render": "^1.0.2",
|
"@react-email/render": "1.0.2",
|
||||||
|
"@sentry/nestjs": "^9.2.0",
|
||||||
|
"@sentry/profiling-node": "^9.2.0",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.29.1",
|
"bullmq": "^5.41.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
"fix-esm": "^1.0.1",
|
"fix-esm": "^1.0.1",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.3.0",
|
||||||
"happy-dom": "^15.11.6",
|
"happy-dom": "^15.11.6",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.5",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.1.0",
|
||||||
"nestjs-kysely": "^1.0.0",
|
"nestjs-kysely": "^1.1.0",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.10.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.3",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"redis": "^4.7.0",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename-ts": "^1.0.2",
|
"sanitize-filename-ts": "^1.0.2",
|
||||||
@@ -75,36 +78,36 @@
|
|||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.20.0",
|
||||||
"@nestjs/cli": "^10.4.8",
|
"@nestjs/cli": "^11.0.4",
|
||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^11.0.1",
|
||||||
"@nestjs/testing": "^10.4.9",
|
"@nestjs/testing": "^11.0.10",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/debounce": "^1.2.4",
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.13.4",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.14",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.20.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"globals": "^15.13.0",
|
"globals": "^15.15.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"kysely-codegen": "^0.17.0",
|
"kysely-codegen": "^0.17.0",
|
||||||
"prettier": "^3.4.1",
|
"prettier": "^3.5.1",
|
||||||
"react-email": "^3.0.2",
|
"react-email": "3.0.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.17.0"
|
"typescript-eslint": "^8.24.1"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { HealthModule } from './integrations/health/health.module';
|
import { HealthModule } from './integrations/health/health.module';
|
||||||
import { ExportModule } from './integrations/export/export.module';
|
import { ExportModule } from './integrations/export/export.module';
|
||||||
import { ImportModule } from './integrations/import/import.module';
|
import { ImportModule } from './integrations/import/import.module';
|
||||||
|
import { SentryModule } from "@sentry/nestjs/setup";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SentryModule.forRoot(),
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -25,21 +25,25 @@ export class CollaborationGateway {
|
|||||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
|
|
||||||
this.hocuspocus = HocuspocusServer.configure({
|
this.hocuspocus = HocuspocusServer.configure({
|
||||||
debounce: 5000,
|
debounce: 10000,
|
||||||
maxDebounce: 10000,
|
maxDebounce: 20000,
|
||||||
unloadImmediately: false,
|
unloadImmediately: false,
|
||||||
extensions: [
|
extensions: [
|
||||||
this.authenticationExtension,
|
this.authenticationExtension,
|
||||||
this.persistenceExtension,
|
this.persistenceExtension,
|
||||||
new Redis({
|
...(this.environmentService.isCollabDisableRedis()
|
||||||
host: this.redisConfig.host,
|
? []
|
||||||
port: this.redisConfig.port,
|
: [
|
||||||
options: {
|
new Redis({
|
||||||
password: this.redisConfig.password,
|
host: this.redisConfig.host,
|
||||||
db: this.redisConfig.db,
|
port: this.redisConfig.port,
|
||||||
retryStrategy: createRetryStrategy(),
|
options: {
|
||||||
},
|
password: this.redisConfig.password,
|
||||||
}),
|
db: this.redisConfig.db,
|
||||||
|
retryStrategy: createRetryStrategy(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -48,6 +52,14 @@ export class CollaborationGateway {
|
|||||||
this.hocuspocus.handleConnection(client, request);
|
this.hocuspocus.handleConnection(client, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConnectionCount() {
|
||||||
|
return this.hocuspocus.getConnectionsCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocumentCount() {
|
||||||
|
return this.hocuspocus.getDocumentsCount();
|
||||||
|
}
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
await this.hocuspocus.destroy();
|
await this.hocuspocus.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { HistoryListener } from './listeners/history.listener';
|
|||||||
PersistenceExtension,
|
PersistenceExtension,
|
||||||
HistoryListener,
|
HistoryListener,
|
||||||
],
|
],
|
||||||
|
exports: [CollaborationGateway],
|
||||||
imports: [TokenModule],
|
imports: [TokenModule],
|
||||||
})
|
})
|
||||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
Mention
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
@@ -75,6 +76,7 @@ export const tiptapExtensions = [
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
Mention
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|||||||
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||||
import { getPageId } from '../collaboration.util';
|
import { getPageId } from '../collaboration.util';
|
||||||
|
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticationExtension implements Extension {
|
export class AuthenticationExtension implements Extension {
|
||||||
@@ -28,12 +29,15 @@ export class AuthenticationExtension implements Extension {
|
|||||||
const { documentName, token } = data;
|
const { documentName, token } = data;
|
||||||
const pageId = getPageId(documentName);
|
const pageId = getPageId(documentName);
|
||||||
|
|
||||||
let jwtPayload = null;
|
let jwtPayload: JwtCollabPayload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
jwtPayload = await this.tokenService.verifyJwt(token);
|
jwtPayload = await this.tokenService.verifyJwt(token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new UnauthorizedException('Could not verify jwt token');
|
throw new UnauthorizedException('Invalid collab token');
|
||||||
|
}
|
||||||
|
if (jwtPayload.type !== JwtType.COLLAB) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = jwtPayload.sub;
|
const userId = jwtPayload.sub;
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import {
|
||||||
|
extractMentions,
|
||||||
|
extractPageMentions,
|
||||||
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
|
||||||
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
@@ -21,6 +31,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
@@ -85,12 +96,13 @@ export class PersistenceExtension implements Extension {
|
|||||||
this.logger.warn('jsonToText' + err?.['message']);
|
this.logger.warn('jsonToText' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let page: Page = null;
|
||||||
let page = null;
|
|
||||||
|
|
||||||
|
try {
|
||||||
await executeTx(this.db, async (trx) => {
|
await executeTx(this.db, async (trx) => {
|
||||||
page = await this.pageRepo.findById(pageId, {
|
page = await this.pageRepo.findById(pageId, {
|
||||||
withLock: true,
|
withLock: true,
|
||||||
|
includeContent: true,
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +111,11 @@ export class PersistenceExtension implements Extension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||||
|
page = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
{
|
{
|
||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
@@ -109,18 +126,30 @@ export class PersistenceExtension implements Extension {
|
|||||||
pageId,
|
pageId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
this.eventEmitter.emit('collab.page.updated', {
|
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||||
page: {
|
|
||||||
...page,
|
|
||||||
lastUpdatedById: context.user.id,
|
|
||||||
content: tiptapJson,
|
|
||||||
textContent: textContent,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to update page ${pageId}`, err);
|
this.logger.error(`Failed to update page ${pageId}`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
this.eventEmitter.emit('collab.page.updated', {
|
||||||
|
page: {
|
||||||
|
...page,
|
||||||
|
content: tiptapJson,
|
||||||
|
lastUpdatedById: context.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentions = extractMentions(tiptapJson);
|
||||||
|
const pageMentions = extractPageMentions(mentions);
|
||||||
|
|
||||||
|
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
|
||||||
|
pageId: pageId,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
mentions: pageMentions,
|
||||||
|
} as IPageBacklinkJob);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppController } from '../../app.controller';
|
||||||
|
import { AppService } from '../../app.service';
|
||||||
|
import { EnvironmentModule } from '../../integrations/environment/environment.module';
|
||||||
|
import { CollaborationModule } from '../collaboration.module';
|
||||||
|
import { DatabaseModule } from '@docmost/db/database.module';
|
||||||
|
import { QueueModule } from '../../integrations/queue/queue.module';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { HealthModule } from '../../integrations/health/health.module';
|
||||||
|
import { CollaborationController } from './collaboration.controller';
|
||||||
|
import { SentryModule } from "@sentry/nestjs/setup";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
SentryModule.forRoot(),
|
||||||
|
DatabaseModule,
|
||||||
|
EnvironmentModule,
|
||||||
|
CollaborationModule,
|
||||||
|
QueueModule,
|
||||||
|
HealthModule,
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
AppController,
|
||||||
|
...(process.env.COLLAB_SHOW_STATS.toLowerCase() === 'true'
|
||||||
|
? [CollaborationController]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class CollabAppModule {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import "./common/sentry/instrument";
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { CollabAppModule } from './collab-app.module';
|
||||||
|
import {
|
||||||
|
FastifyAdapter,
|
||||||
|
NestFastifyApplication,
|
||||||
|
} from '@nestjs/platform-fastify';
|
||||||
|
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
||||||
|
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
CollabAppModule,
|
||||||
|
new FastifyAdapter({
|
||||||
|
ignoreTrailingSlash: true,
|
||||||
|
ignoreDuplicateSlashes: true,
|
||||||
|
maxParamLength: 500,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
logger: new InternalLogFilter(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api', { exclude: ['/'] });
|
||||||
|
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
const logger = new Logger('CollabServer');
|
||||||
|
|
||||||
|
const port = process.env.COLLAB_PORT || 3001;
|
||||||
|
await app.listen(port, '0.0.0.0', () => {
|
||||||
|
logger.log(`Listening on http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { CollaborationGateway } from '../collaboration.gateway';
|
||||||
|
|
||||||
|
@Controller('collab')
|
||||||
|
export class CollaborationController {
|
||||||
|
constructor(private readonly collaborationGateway: CollaborationGateway) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
async getStats() {
|
||||||
|
return {
|
||||||
|
connections: this.collaborationGateway.getConnectionCount(),
|
||||||
|
documents: this.collaborationGateway.getDocumentCount(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||||
|
|
||||||
|
export interface MentionNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
entityType: 'user' | 'page';
|
||||||
|
entityId: string;
|
||||||
|
creatorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMentions(prosemirrorJson: any) {
|
||||||
|
const mentionList: MentionNode[] = [];
|
||||||
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
|
|
||||||
|
doc.descendants((node: Node) => {
|
||||||
|
if (node.type.name === 'mention') {
|
||||||
|
if (
|
||||||
|
node.attrs.id &&
|
||||||
|
!mentionList.some((mention) => mention.id === node.attrs.id)
|
||||||
|
) {
|
||||||
|
mentionList.push({
|
||||||
|
id: node.attrs.id,
|
||||||
|
label: node.attrs.label,
|
||||||
|
entityType: node.attrs.entityType,
|
||||||
|
entityId: node.attrs.entityId,
|
||||||
|
creatorId: node.attrs.creatorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return mentionList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUserMentions(mentionList: MentionNode[]): MentionNode[] {
|
||||||
|
const userList = [];
|
||||||
|
for (const mention of mentionList) {
|
||||||
|
if (mention.entityType === 'user') {
|
||||||
|
userList.push(mention);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userList as MentionNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
|
||||||
|
const pageMentionList = [];
|
||||||
|
for (const mention of mentionList) {
|
||||||
|
if (
|
||||||
|
mention.entityType === 'page' &&
|
||||||
|
!pageMentionList.some(
|
||||||
|
(pageMention) => pageMention.entityId === mention.entityId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
pageMentionList.push(mention);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pageMentionList as MentionNode[];
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
|
|||||||
// extract db value if present
|
// extract db value if present
|
||||||
if (pathname.length > 1) {
|
if (pathname.length > 1) {
|
||||||
const value = pathname.slice(1);
|
const value = pathname.slice(1);
|
||||||
if (!isNaN(parseInt(value))){
|
if (!isNaN(parseInt(value))) {
|
||||||
db = parseInt(value, 10);
|
db = parseInt(value, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,3 +44,12 @@ export function createRetryStrategy() {
|
|||||||
return Math.max(Math.min(Math.exp(times), 20000), 3000);
|
return Math.max(Math.min(Math.exp(times), 20000), 3000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractDateFromUuid7(uuid7: string) {
|
||||||
|
//https://park.is/blog_posts/20240803_extracting_timestamp_from_uuid_v7/
|
||||||
|
const parts = uuid7.split('-');
|
||||||
|
const highBitsHex = parts[0] + parts[1].slice(0, 4);
|
||||||
|
const timestamp = parseInt(highBitsHex, 16);
|
||||||
|
|
||||||
|
return new Date(timestamp);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as Sentry from '@sentry/nestjs';
|
||||||
|
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||||
|
import { envPath } from '../helpers';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
|
if (process.env.SENTRY_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
integrations: [
|
||||||
|
nodeProfilingIntegration(),
|
||||||
|
],
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
profilesSampleRate: 1.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service';
|
|||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
import { Space } from '@docmost/db/types/entity.types';
|
import { Space } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
@Processor(QueueName.ATTACHEMENT_QUEUE)
|
@Processor(QueueName.ATTACHMENT_QUEUE)
|
||||||
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(AttachmentProcessor.name);
|
private readonly logger = new Logger(AttachmentProcessor.name);
|
||||||
constructor(private readonly attachmentService: AttachmentService) {
|
constructor(private readonly attachmentService: AttachmentService) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -21,6 +22,8 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
import { PasswordResetDto } from './dto/password-reset.dto';
|
import { PasswordResetDto } from './dto/password-reset.dto';
|
||||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { addDays } from 'date-fns';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -31,26 +34,29 @@ export class AuthController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Req() req, @Body() loginInput: LoginDto) {
|
async login(
|
||||||
return this.authService.login(loginInput, req.raw.workspaceId);
|
@Req() req,
|
||||||
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
|
@Body() loginInput: LoginDto,
|
||||||
|
) {
|
||||||
|
const authToken = await this.authService.login(
|
||||||
|
loginInput,
|
||||||
|
req.raw.workspaceId,
|
||||||
|
);
|
||||||
|
this.setAuthCookie(res, authToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @HttpCode(HttpStatus.OK)
|
|
||||||
@Post('register')
|
|
||||||
async register(@Req() req, @Body() createUserDto: CreateUserDto) {
|
|
||||||
return this.authService.register(createUserDto, req.raw.workspaceId);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
@UseGuards(SetupGuard)
|
@UseGuards(SetupGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('setup')
|
@Post('setup')
|
||||||
async setupWorkspace(
|
async setupWorkspace(
|
||||||
@Req() req,
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
@Body() createAdminUserDto: CreateAdminUserDto,
|
@Body() createAdminUserDto: CreateAdminUserDto,
|
||||||
) {
|
) {
|
||||||
if (this.environmentService.isCloud()) throw new NotFoundException();
|
if (this.environmentService.isCloud()) throw new NotFoundException();
|
||||||
return this.authService.setup(createAdminUserDto);
|
|
||||||
|
const authToken = await this.authService.setup(createAdminUserDto);
|
||||||
|
this.setAuthCookie(res, authToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -76,10 +82,15 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('password-reset')
|
@Post('password-reset')
|
||||||
async passwordReset(
|
async passwordReset(
|
||||||
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
@Body() passwordResetDto: PasswordResetDto,
|
@Body() passwordResetDto: PasswordResetDto,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.authService.passwordReset(passwordResetDto, workspace.id);
|
const authToken = await this.authService.passwordReset(
|
||||||
|
passwordResetDto,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
this.setAuthCookie(res, authToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -90,4 +101,30 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
|
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('collab-token')
|
||||||
|
async collabToken(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return this.authService.getCollabToken(user.id, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Res({ passthrough: true }) res: FastifyReply) {
|
||||||
|
res.clearCookie('authToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthCookie(res: FastifyReply, token: string) {
|
||||||
|
res.setCookie('authToken', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
expires: addDays(new Date(), 30),
|
||||||
|
secure: this.environmentService.isHttps(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export enum JwtType {
|
export enum JwtType {
|
||||||
ACCESS = 'access',
|
ACCESS = 'access',
|
||||||
REFRESH = 'refresh',
|
COLLAB = 'collab',
|
||||||
}
|
}
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -9,8 +9,8 @@ export type JwtPayload = {
|
|||||||
type: 'access';
|
type: 'access';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JwtRefreshPayload = {
|
export type JwtCollabPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: 'refresh';
|
type: 'collab';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface TokensDto {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
import { LoginDto } from '../dto/login.dto';
|
import { LoginDto } from '../dto/login.dto';
|
||||||
import { CreateUserDto } from '../dto/create-user.dto';
|
import { CreateUserDto } from '../dto/create-user.dto';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
import { TokensDto } from '../dto/tokens.dto';
|
|
||||||
import { SignupService } from './signup.service';
|
import { SignupService } from './signup.service';
|
||||||
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
|
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
@@ -60,24 +59,17 @@ export class AuthService {
|
|||||||
user.lastLoginAt = new Date();
|
user.lastLoginAt = new Date();
|
||||||
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
||||||
|
|
||||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
return this.tokenService.generateAccessToken(user);
|
||||||
return { tokens };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(createUserDto: CreateUserDto, workspaceId: string) {
|
async register(createUserDto: CreateUserDto, workspaceId: string) {
|
||||||
const user = await this.signupService.signup(createUserDto, workspaceId);
|
const user = await this.signupService.signup(createUserDto, workspaceId);
|
||||||
|
return this.tokenService.generateAccessToken(user);
|
||||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
|
||||||
|
|
||||||
return { tokens };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setup(createAdminUserDto: CreateAdminUserDto) {
|
async setup(createAdminUserDto: CreateAdminUserDto) {
|
||||||
const user = await this.signupService.initialSetup(createAdminUserDto);
|
const user = await this.signupService.initialSetup(createAdminUserDto);
|
||||||
|
return this.tokenService.generateAccessToken(user);
|
||||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
|
||||||
|
|
||||||
return { tokens };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@@ -186,7 +178,7 @@ export class AuthService {
|
|||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
trx
|
await trx
|
||||||
.deleteFrom('userTokens')
|
.deleteFrom('userTokens')
|
||||||
.where('userId', '=', user.id)
|
.where('userId', '=', user.id)
|
||||||
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
|
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
|
||||||
@@ -200,9 +192,7 @@ export class AuthService {
|
|||||||
template: emailTemplate,
|
template: emailTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
return this.tokenService.generateAccessToken(user);
|
||||||
|
|
||||||
return { tokens };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyUserToken(
|
async verifyUserToken(
|
||||||
@@ -222,4 +212,12 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Invalid or expired token');
|
throw new BadRequestException('Invalid or expired token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCollabToken(userId: string, workspaceId: string) {
|
||||||
|
const token = await this.tokenService.generateCollabToken(
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,11 @@ export class SignupService {
|
|||||||
this.db,
|
this.db,
|
||||||
async (trx) => {
|
async (trx) => {
|
||||||
// create user
|
// create user
|
||||||
|
|
||||||
const user = await this.userRepo.insertUser(
|
const user = await this.userRepo.insertUser(
|
||||||
{
|
{
|
||||||
...createAdminUserDto,
|
name: createAdminUserDto.name,
|
||||||
|
email: createAdminUserDto.email,
|
||||||
|
password: createAdminUserDto.password,
|
||||||
role: UserRole.OWNER,
|
role: UserRole.OWNER,
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import { TokensDto } from '../dto/tokens.dto';
|
import { JwtCollabPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||||
import { JwtPayload, JwtRefreshPayload, JwtType } from '../dto/jwt-payload';
|
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -22,26 +21,19 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload);
|
return this.jwtService.sign(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateRefreshToken(
|
async generateCollabToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const payload: JwtRefreshPayload = {
|
const payload: JwtCollabPayload = {
|
||||||
sub: userId,
|
sub: userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
type: JwtType.REFRESH,
|
type: JwtType.COLLAB,
|
||||||
};
|
};
|
||||||
const expiresIn = this.environmentService.getJwtTokenExpiresIn();
|
const expiresIn = '24h';
|
||||||
return this.jwtService.sign(payload, { expiresIn });
|
return this.jwtService.sign(payload, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateTokens(user: User): Promise<TokensDto> {
|
|
||||||
return {
|
|
||||||
accessToken: await this.generateAccessToken(user),
|
|
||||||
refreshToken: await this.generateRefreshToken(user.id, user.workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyJwt(token: string) {
|
async verifyJwt(token: string) {
|
||||||
return this.jwtService.verifyAsync(token, {
|
return this.jwtService.verifyAsync(token, {
|
||||||
secret: this.environmentService.getAppSecret(),
|
secret: this.environmentService.getAppSecret(),
|
||||||
|
|||||||
@@ -23,15 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: (req: FastifyRequest) => {
|
jwtFromRequest: (req: FastifyRequest) => {
|
||||||
let accessToken = null;
|
return req.cookies?.authToken || this.extractTokenFromHeader(req);
|
||||||
|
|
||||||
try {
|
|
||||||
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
|
|
||||||
} catch {
|
|
||||||
this.logger.debug('Failed to parse access token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return accessToken || this.extractTokenFromHeader(req);
|
|
||||||
},
|
},
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: environmentService.getAppSecret(),
|
secretOrKey: environmentService.getAppSecret(),
|
||||||
|
|||||||
@@ -33,9 +33,21 @@ export class SearchSuggestionDTO {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeUsers?: string;
|
includeUsers?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeGroups?: number;
|
includeGroups?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
includePages?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
spaceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,13 @@ export class SearchController {
|
|||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('suggest')
|
@Post('suggest')
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
@Body() dto: SearchSuggestionDTO,
|
@Body() dto: SearchSuggestionDTO,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.searchService.searchSuggestions(dto, workspace.id);
|
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const tsquery = require('pg-tsquery')();
|
const tsquery = require('pg-tsquery')();
|
||||||
@@ -14,6 +15,7 @@ export class SearchService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchPage(
|
async searchPage(
|
||||||
@@ -29,15 +31,15 @@ export class SearchService {
|
|||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
'id',
|
'id',
|
||||||
|
'slugId',
|
||||||
'title',
|
'title',
|
||||||
'icon',
|
'icon',
|
||||||
'parentPageId',
|
'parentPageId',
|
||||||
'slugId',
|
|
||||||
'creatorId',
|
'creatorId',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
|
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
|
||||||
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as(
|
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
||||||
'highlight',
|
'highlight',
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -66,35 +68,59 @@ export class SearchService {
|
|||||||
|
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
suggestion: SearchSuggestionDTO,
|
suggestion: SearchSuggestionDTO,
|
||||||
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
) {
|
) {
|
||||||
const limit = 25;
|
|
||||||
|
|
||||||
const userSearch = this.db
|
|
||||||
.selectFrom('users')
|
|
||||||
.select(['id', 'name', 'avatarUrl'])
|
|
||||||
.where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`))
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
const groupSearch = this.db
|
|
||||||
.selectFrom('groups')
|
|
||||||
.select(['id', 'name', 'description'])
|
|
||||||
.where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`))
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
let users = [];
|
let users = [];
|
||||||
let groups = [];
|
let groups = [];
|
||||||
|
let pages = [];
|
||||||
|
|
||||||
|
const limit = suggestion?.limit || 10;
|
||||||
|
const query = suggestion.query.toLowerCase().trim();
|
||||||
|
|
||||||
if (suggestion.includeUsers) {
|
if (suggestion.includeUsers) {
|
||||||
users = await userSearch.execute();
|
users = await this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'name', 'avatarUrl'])
|
||||||
|
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.limit(limit)
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suggestion.includeGroups) {
|
if (suggestion.includeGroups) {
|
||||||
groups = await groupSearch.execute();
|
groups = await this.db
|
||||||
|
.selectFrom('groups')
|
||||||
|
.select(['id', 'name', 'description'])
|
||||||
|
.where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`))
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.limit(limit)
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { users, groups };
|
if (suggestion.includePages) {
|
||||||
|
let pageSearch = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
|
||||||
|
.where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`))
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
// only search spaces the user has access to
|
||||||
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||||
|
|
||||||
|
if (suggestion?.spaceId) {
|
||||||
|
if (userSpaceIds.includes(suggestion.spaceId)) {
|
||||||
|
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
|
||||||
|
pages = await pageSearch.execute();
|
||||||
|
}
|
||||||
|
} else if (userSpaceIds?.length > 0) {
|
||||||
|
// we need this check or the query will throw an error if the userSpaceIds array is empty
|
||||||
|
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
|
||||||
|
pages = await pageSearch.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, groups, pages };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SpaceService {
|
|||||||
private spaceRepo: SpaceRepo,
|
private spaceRepo: SpaceRepo,
|
||||||
private spaceMemberService: SpaceMemberService,
|
private spaceMemberService: SpaceMemberService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createSpace(
|
async createSpace(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { WorkspaceService } from '../services/workspace.service';
|
import { WorkspaceService } from '../services/workspace.service';
|
||||||
@@ -29,6 +30,9 @@ import {
|
|||||||
WorkspaceCaslAction,
|
WorkspaceCaslAction,
|
||||||
WorkspaceCaslSubject,
|
WorkspaceCaslSubject,
|
||||||
} from '../../casl/interfaces/workspace-ability.type';
|
} from '../../casl/interfaces/workspace-ability.type';
|
||||||
|
import { addDays } from 'date-fns';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('workspace')
|
@Controller('workspace')
|
||||||
@@ -37,6 +41,7 @@ export class WorkspaceController {
|
|||||||
private readonly workspaceService: WorkspaceService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
|
private environmentService: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -218,10 +223,18 @@ export class WorkspaceController {
|
|||||||
async acceptInvite(
|
async acceptInvite(
|
||||||
@Body() acceptInviteDto: AcceptInviteDto,
|
@Body() acceptInviteDto: AcceptInviteDto,
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
return this.workspaceInvitationService.acceptInvitation(
|
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
||||||
acceptInviteDto,
|
acceptInviteDto,
|
||||||
req.raw.workspaceId,
|
req.raw.workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
res.setCookie('authToken', authToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
expires: addDays(new Date(), 30),
|
||||||
|
secure: this.environmentService.isHttps(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { MailService } from '../../../integrations/mail/mail.service';
|
import { MailService } from '../../../integrations/mail/mail.service';
|
||||||
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||||
import { hashPassword } from '../../../common/helpers';
|
|
||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
@@ -24,7 +23,6 @@ import { TokenService } from '../../auth/services/token.service';
|
|||||||
import { nanoIdGen } from '../../../common/helpers';
|
import { nanoIdGen } from '../../../common/helpers';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { TokensDto } from '../../auth/dto/tokens.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceInvitationService {
|
export class WorkspaceInvitationService {
|
||||||
@@ -164,25 +162,22 @@ export class WorkspaceInvitationService {
|
|||||||
throw new BadRequestException('Invalid invitation token');
|
throw new BadRequestException('Invalid invitation token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = await hashPassword(dto.password);
|
|
||||||
let newUser: User;
|
let newUser: User;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeTx(this.db, async (trx) => {
|
await executeTx(this.db, async (trx) => {
|
||||||
newUser = await trx
|
newUser = await this.userRepo.insertUser(
|
||||||
.insertInto('users')
|
{
|
||||||
.values({
|
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
password: password,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
role: invitation.role,
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
invitedById: invitation.invitedById,
|
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
})
|
password: dto.password,
|
||||||
.returningAll()
|
role: invitation.role,
|
||||||
.executeTakeFirst();
|
invitedById: invitation.invitedById,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
// add user to default group
|
// add user to default group
|
||||||
await this.groupUserRepo.addUserToDefaultGroup(
|
await this.groupUserRepo.addUserToDefaultGroup(
|
||||||
@@ -254,8 +249,7 @@ export class WorkspaceInvitationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens: TokensDto = await this.tokenService.generateTokens(newUser);
|
return this.tokenService.generateAccessToken(newUser);
|
||||||
return { tokens };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resendInvitation(
|
async resendInvitation(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|||||||
import * as process from 'node:process';
|
import * as process from 'node:process';
|
||||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||||
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||||
|
|
||||||
// https://github.com/brianc/node-postgres/issues/811
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||||
@@ -68,6 +69,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
|
BacklinkRepo,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WorkspaceRepo,
|
WorkspaceRepo,
|
||||||
@@ -81,6 +83,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
|
BacklinkRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('backlinks')
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
|
)
|
||||||
|
.addColumn('source_page_id', 'uuid', (col) =>
|
||||||
|
col.references('pages.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('target_page_id', 'uuid', (col) =>
|
||||||
|
col.references('pages.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addUniqueConstraint('backlinks_source_page_id_target_page_id_unique', [
|
||||||
|
'source_page_id',
|
||||||
|
'target_page_id',
|
||||||
|
])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('backlinks').execute();
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Backlink,
|
||||||
|
InsertableBacklink,
|
||||||
|
UpdatableBacklink,
|
||||||
|
} from '@docmost/db/types/entity.types';
|
||||||
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BacklinkRepo {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
backlinkId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<Backlink> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.selectFrom('backlinks')
|
||||||
|
.select([
|
||||||
|
'id',
|
||||||
|
'sourcePageId',
|
||||||
|
'targetPageId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
])
|
||||||
|
.where('id', '=', backlinkId)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertBacklink(
|
||||||
|
insertableBacklink: InsertableBacklink,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.insertInto('backlinks')
|
||||||
|
.values(insertableBacklink)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.columns(['sourcePageId', 'targetPageId']).doNothing(),
|
||||||
|
)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBacklink(
|
||||||
|
updatableBacklink: UpdatableBacklink,
|
||||||
|
backlinkId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.updateTable('userTokens')
|
||||||
|
.set(updatableBacklink)
|
||||||
|
.where('id', '=', backlinkId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBacklink(
|
||||||
|
backlinkId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,7 +166,16 @@ export class PageRepo {
|
|||||||
.withRecursive('page_hierarchy', (db) =>
|
.withRecursive('page_hierarchy', (db) =>
|
||||||
db
|
db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'content',
|
||||||
|
'parentPageId',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
])
|
||||||
.where('id', '=', parentPageId)
|
.where('id', '=', parentPageId)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
@@ -179,6 +188,7 @@ export class PageRepo {
|
|||||||
'p.content',
|
'p.content',
|
||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
|
'p.workspaceId',
|
||||||
])
|
])
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export class SpaceMemberRepo {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
) {
|
) {
|
||||||
const query = this.db
|
let query = this.db
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
||||||
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
||||||
@@ -116,6 +116,16 @@ export class SpaceMemberRepo {
|
|||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.orderBy('spaceMembers.createdAt', 'asc');
|
.orderBy('spaceMembers.createdAt', 'asc');
|
||||||
|
|
||||||
|
if (pagination.query) {
|
||||||
|
query = query.where((eb) =>
|
||||||
|
eb('users.name', 'ilike', `%${pagination.query}%`).or(
|
||||||
|
'groups.name',
|
||||||
|
'ilike',
|
||||||
|
`%${pagination.query}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await executeWithPagination(query, {
|
const result = await executeWithPagination(query, {
|
||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user