mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
PORT=3000
|
||||
|
||||
# make sure to replace this.
|
||||
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||
|
||||
JWT_TOKEN_EXPIRES_IN=30d
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -24,9 +24,8 @@
|
||||
"@mantine/spotlight": "^7.14.2",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"axios": "^1.7.8",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"i18next": "^23.14.0",
|
||||
@@ -34,12 +33,11 @@
|
||||
"jotai": "^2.10.3",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.11",
|
||||
"katex": "0.16.21",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "^3.4.0",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
@@ -74,6 +72,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,7 @@
|
||||
"Align left": "Align left",
|
||||
"Align right": "Align right",
|
||||
"Align center": "Align center",
|
||||
"Justify": "Justify",
|
||||
"Merge cells": "Merge cells",
|
||||
"Split cell": "Split cell",
|
||||
"Delete column": "Delete column",
|
||||
|
||||
@@ -1,342 +1,342 @@
|
||||
{
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
"Add group members": "Add group members",
|
||||
"Add groups": "Add groups",
|
||||
"Add members": "Add members",
|
||||
"Add to groups": "Add to groups",
|
||||
"Add space members": "Add space members",
|
||||
"Admin": "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 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 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 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 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.": "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 become members of groups and spaces in workspace",
|
||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||
"Can edit": "Can edit",
|
||||
"Can manage workspace": "Can manage workspace",
|
||||
"Can manage workspace but cannot delete it": "Can manage workspace but cannot delete it",
|
||||
"Can view": "Can view",
|
||||
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
||||
"Cancel": "Cancel",
|
||||
"Change email": "Change email",
|
||||
"Change password": "Change password",
|
||||
"Change photo": "Change photo",
|
||||
"Choose a role": "Choose a role",
|
||||
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||
"Confirm": "Confirm",
|
||||
"Copy link": "Copy link",
|
||||
"Create": "Create",
|
||||
"Create group": "Create group",
|
||||
"Create page": "Create page",
|
||||
"Create space": "Create space",
|
||||
"Create workspace": "Create workspace",
|
||||
"Current password": "Current password",
|
||||
"Dark": "Dark",
|
||||
"Date": "Date",
|
||||
"Delete": "Delete",
|
||||
"Delete group": "Delete group",
|
||||
"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.",
|
||||
"Description": "Description",
|
||||
"Details": "Details",
|
||||
"e.g ACME": "e.g ACME",
|
||||
"e.g ACME Inc": "e.g ACME Inc",
|
||||
"e.g Developers": "e.g Developers",
|
||||
"e.g Group for developers": "e.g Group for developers",
|
||||
"e.g product": "e.g product",
|
||||
"e.g Product Team": "e.g Product Team",
|
||||
"e.g Sales": "e.g Sales",
|
||||
"e.g Space for product team": "e.g Space for product team",
|
||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||
"Edit": "Edit",
|
||||
"Edit group": "Edit group",
|
||||
"Email": "Email",
|
||||
"Enter a strong password": "Enter a strong password",
|
||||
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]",
|
||||
"enter valid emails addresses": "enter valid emails addresses",
|
||||
"Enter your current password": "Enter your current password",
|
||||
"enter your full name": "enter your full name",
|
||||
"Enter your new password": "Enter your new password",
|
||||
"Enter your new preferred email": "Enter your new preferred email",
|
||||
"Enter your password": "Enter your password",
|
||||
"Error fetching page data.": "Error fetching page data.",
|
||||
"Error loading page history.": "Error loading page history.",
|
||||
"Export": "Export",
|
||||
"Failed to create page": "Failed to create page",
|
||||
"Failed to delete page": "Failed to delete page",
|
||||
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
||||
"Failed to import pages": "Failed to import pages",
|
||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||
"Failed to update data": "Failed to update data",
|
||||
"Full access": "Full access",
|
||||
"Full page width": "Full page width",
|
||||
"Full width": "Full width",
|
||||
"Account": "Cuenta",
|
||||
"Active": "Activo",
|
||||
"Add": "Agregar",
|
||||
"Add group members": "Agregar miembros del grupo",
|
||||
"Add groups": "Agregar grupos",
|
||||
"Add members": "Agregar miembros",
|
||||
"Add to groups": "Agregar a grupos",
|
||||
"Add space members": "Agregar miembros al espacio",
|
||||
"Admin": "Administrador",
|
||||
"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?": "¿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.": "¿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.": "¿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.": "¿Está seguro de que desea restaurar esta versión? Cualquier cambio no versionado se perderá.",
|
||||
"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.": "Puede crear y editar páginas en el espacio.",
|
||||
"Can edit": "Puede editar",
|
||||
"Can manage workspace": "Puede gestionar el espacio de trabajo",
|
||||
"Can manage workspace but cannot delete it": "Puede gestionar el espacio de trabajo pero no puede eliminarlo",
|
||||
"Can view": "Puede ver",
|
||||
"Can view pages in space but not edit.": "Puede ver páginas en el espacio pero no editarlas.",
|
||||
"Cancel": "Cancelar",
|
||||
"Change email": "Cambiar correo electrónico",
|
||||
"Change password": "Cambiar contraseña",
|
||||
"Change photo": "Cambiar foto",
|
||||
"Choose a role": "Seleccione un rol",
|
||||
"Choose your preferred color scheme.": "Elige tu esquema de color preferido.",
|
||||
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||
"Confirm": "Confirmar",
|
||||
"Copy link": "Copiar enlace",
|
||||
"Create": "Crear",
|
||||
"Create group": "Crear grupo",
|
||||
"Create page": "Crear página",
|
||||
"Create space": "Crear espacio",
|
||||
"Create workspace": "Crear espacio de trabajo",
|
||||
"Current password": "Contraseña actual",
|
||||
"Dark": "Oscuro",
|
||||
"Date": "Fecha",
|
||||
"Delete": "Eliminar",
|
||||
"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.": "¿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": "Descripción",
|
||||
"Details": "Detalles",
|
||||
"e.g ACME": "ej: ACME",
|
||||
"e.g ACME Inc": "ej: ACME Inc",
|
||||
"e.g Developers": "ej: Desarrolladores",
|
||||
"e.g Group for developers": "ej: Grupo para desarrolladores",
|
||||
"e.g product": "ej: producto",
|
||||
"e.g Product Team": "ej: Equipo de Producto",
|
||||
"e.g Sales": "ej: Ventas",
|
||||
"e.g Space for product team": "ej: Espacio para el equipo de producto",
|
||||
"e.g Space for sales team to collaborate": "ej: Espacio para que el equipo de ventas colabore",
|
||||
"Edit": "Editar",
|
||||
"Edit group": "Editar grupo",
|
||||
"Email": "Correo electrónico",
|
||||
"Enter a strong password": "Introduce una contraseña fuerte",
|
||||
"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": "introduce direcciones de correo electrónico válidas",
|
||||
"Enter your current password": "Introduce tu contraseña actual",
|
||||
"enter your full name": "introduzca su nombre completo",
|
||||
"Enter your new password": "Ingrese su nueva contraseña",
|
||||
"Enter your new preferred email": "Introduce tu nuevo correo electrónico preferido",
|
||||
"Enter your password": "Introduce tu contraseña",
|
||||
"Error fetching page data.": "Error al obtener los datos de la página.",
|
||||
"Error loading page history.": "Error al cargar el historial de la página.",
|
||||
"Export": "Exportar",
|
||||
"Failed to create page": "No se pudo crear la página",
|
||||
"Failed to delete page": "No se pudo eliminar la página",
|
||||
"Failed to fetch recent pages": "Error al obtener las páginas recientes",
|
||||
"Failed to import pages": "No se pudieron importar las páginas",
|
||||
"Failed to load page. An error occurred.": "Error al cargar la página. Se produjo un error.",
|
||||
"Failed to update data": "No se pudo actualizar los datos",
|
||||
"Full access": "Acceso completo",
|
||||
"Full page width": "Ancho de página completa",
|
||||
"Full width": "Ancho completo",
|
||||
"General": "General",
|
||||
"Group": "Group",
|
||||
"Group description": "Group description",
|
||||
"Group name": "Group name",
|
||||
"Groups": "Groups",
|
||||
"Has full access to space settings and pages.": "Has full access to space settings and pages.",
|
||||
"Home": "Home",
|
||||
"Import pages": "Import pages",
|
||||
"Import pages & space settings": "Import pages & space settings",
|
||||
"Importing pages": "Importing pages",
|
||||
"invalid invitation link": "invalid invitation link",
|
||||
"Invitation signup": "Invitation signup",
|
||||
"Invite by email": "Invite by email",
|
||||
"Invite members": "Invite members",
|
||||
"Invite new members": "Invite new members",
|
||||
"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 will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
|
||||
"Join the workspace": "Join the workspace",
|
||||
"Language": "Language",
|
||||
"Light": "Light",
|
||||
"Link copied": "Link copied",
|
||||
"Login": "Login",
|
||||
"Logout": "Logout",
|
||||
"Manage Group": "Manage Group",
|
||||
"Manage members": "Manage members",
|
||||
"member": "member",
|
||||
"Member": "Member",
|
||||
"members": "members",
|
||||
"Members": "Members",
|
||||
"My preferences": "My preferences",
|
||||
"My Profile": "My Profile",
|
||||
"My profile": "My profile",
|
||||
"Name": "Name",
|
||||
"New email": "New email",
|
||||
"New page": "New page",
|
||||
"New password": "New password",
|
||||
"No group found": "No group found",
|
||||
"No page history saved yet.": "No page history saved yet.",
|
||||
"No pages yet": "No pages yet",
|
||||
"No results found...": "No results found...",
|
||||
"No user found": "No user found",
|
||||
"Overview": "Overview",
|
||||
"Owner": "Owner",
|
||||
"page": "page",
|
||||
"Page deleted successfully": "Page deleted successfully",
|
||||
"Page history": "Page history",
|
||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||
"Pages": "Pages",
|
||||
"pages": "pages",
|
||||
"Password": "Password",
|
||||
"Password changed successfully": "Password changed successfully",
|
||||
"Pending": "Pending",
|
||||
"Please confirm your action": "Please confirm your action",
|
||||
"Preferences": "Preferences",
|
||||
"Print PDF": "Print PDF",
|
||||
"Profile": "Profile",
|
||||
"Recently updated": "Recently updated",
|
||||
"Remove": "Remove",
|
||||
"Remove group member": "Remove group member",
|
||||
"Remove space member": "Remove space member",
|
||||
"Restore": "Restore",
|
||||
"Role": "Role",
|
||||
"Save": "Save",
|
||||
"Search": "Search",
|
||||
"Search for groups": "Search for groups",
|
||||
"Search for users": "Search for users",
|
||||
"Search for users and groups": "Search for users and groups",
|
||||
"Search...": "Search...",
|
||||
"Select language": "Select language",
|
||||
"Select role": "Select role",
|
||||
"Select role to assign to all invited members": "Select role to assign to all invited members",
|
||||
"Select theme": "Select theme",
|
||||
"Send invitation": "Send invitation",
|
||||
"Settings": "Settings",
|
||||
"Setup workspace": "Setup workspace",
|
||||
"Sign In": "Sign In",
|
||||
"Sign Up": "Sign Up",
|
||||
"Slug": "Slug",
|
||||
"Space": "Space",
|
||||
"Space description": "Space description",
|
||||
"Space menu": "Space menu",
|
||||
"Space name": "Space name",
|
||||
"Space settings": "Space settings",
|
||||
"Space slug": "Space slug",
|
||||
"Spaces": "Spaces",
|
||||
"Spaces you belong to": "Spaces you belong to",
|
||||
"No space found": "No space found",
|
||||
"Search for spaces": "Search for spaces",
|
||||
"Start typing to search...": "Start typing to search...",
|
||||
"Status": "Status",
|
||||
"Successfully imported": "Successfully imported",
|
||||
"Successfully restored": "Successfully restored",
|
||||
"System settings": "System settings",
|
||||
"Theme": "Theme",
|
||||
"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.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||
"untitled": "untitled",
|
||||
"Untitled": "Untitled",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"User": "User",
|
||||
"Workspace": "Workspace",
|
||||
"Workspace Name": "Workspace Name",
|
||||
"Workspace settings": "Workspace settings",
|
||||
"You can change your password here.": "You can change your password here.",
|
||||
"Your Email": "Your Email",
|
||||
"Your import is complete.": "Your import is complete.",
|
||||
"Your name": "Your name",
|
||||
"Your Name": "Your Name",
|
||||
"Your password": "Your password",
|
||||
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
|
||||
"Sidebar toggle": "Sidebar toggle",
|
||||
"Comments": "Comments",
|
||||
"404 page not found": "404 page not found",
|
||||
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
|
||||
"Take me back to homepage": "Take me back to homepage",
|
||||
"Forgot password": "Forgot password",
|
||||
"Forgot your password?": "Forgot your password?",
|
||||
"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.",
|
||||
"Send reset link": "Send reset link",
|
||||
"Password reset": "Password reset",
|
||||
"Your new password": "Your new password",
|
||||
"Set password": "Set password",
|
||||
"Write a comment": "Write a comment",
|
||||
"Reply...": "Reply...",
|
||||
"Error loading comments.": "Error loading comments.",
|
||||
"No comments yet.": "No comments yet.",
|
||||
"Edit comment": "Edit comment",
|
||||
"Delete comment": "Delete comment",
|
||||
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
||||
"Comment created successfully": "Comment created successfully",
|
||||
"Error creating comment": "Error creating comment",
|
||||
"Comment updated successfully": "Comment updated successfully",
|
||||
"Failed to update comment": "Failed to update comment",
|
||||
"Comment deleted successfully": "Comment deleted successfully",
|
||||
"Failed to delete comment": "Failed to delete comment",
|
||||
"Comment resolved successfully": "Comment resolved successfully",
|
||||
"Failed to resolve comment": "Failed to resolve comment",
|
||||
"Revoke invitation": "Revoke invitation",
|
||||
"Revoke": "Revoke",
|
||||
"Don't": "Don't",
|
||||
"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.",
|
||||
"Resend invitation": "Resend invitation",
|
||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copied": "Copied",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||
"Delete space": "Delete space",
|
||||
"Are you sure you want to delete this space?": "Are you sure you want to delete this space?",
|
||||
"Delete this space with all its pages and data.": "Delete this space with all its pages and data.",
|
||||
"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.",
|
||||
"Confirm space name": "Confirm space name",
|
||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Type the space name <b>{{spaceName}}</b> to confirm your action.",
|
||||
"Format": "Format",
|
||||
"Include subpages": "Include subpages",
|
||||
"Include attachments": "Include attachments",
|
||||
"Select export format": "Select export format",
|
||||
"Export failed:": "Export failed:",
|
||||
"export error": "export error",
|
||||
"Export page": "Export page",
|
||||
"Export space": "Export space",
|
||||
"Export {{type}}": "Export {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||
"Align left": "Align left",
|
||||
"Align right": "Align right",
|
||||
"Align center": "Align center",
|
||||
"Merge cells": "Merge cells",
|
||||
"Split cell": "Split cell",
|
||||
"Delete column": "Delete column",
|
||||
"Delete row": "Delete row",
|
||||
"Add left column": "Add left column",
|
||||
"Add right column": "Add right column",
|
||||
"Add row above": "Add row above",
|
||||
"Add row below": "Add row below",
|
||||
"Delete table": "Delete table",
|
||||
"Info": "Info",
|
||||
"Success": "Success",
|
||||
"Warning": "Warning",
|
||||
"Danger": "Danger",
|
||||
"Mermaid diagram error:": "Mermaid diagram error:",
|
||||
"Invalid Mermaid diagram": "Invalid Mermaid diagram",
|
||||
"Double-click to edit Draw.io diagram": "Double-click to edit Draw.io diagram",
|
||||
"Exit": "Exit",
|
||||
"Save & Exit": "Save & Exit",
|
||||
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
|
||||
"Paste link": "Paste link",
|
||||
"Edit link": "Edit link",
|
||||
"Remove link": "Remove link",
|
||||
"Add link": "Add link",
|
||||
"Please enter a valid url": "Please enter a valid url",
|
||||
"Empty equation": "Empty equation",
|
||||
"Invalid equation": "Invalid equation",
|
||||
"Group": "Grupo",
|
||||
"Group description": "Descripción del grupo",
|
||||
"Group name": "Nombre del grupo",
|
||||
"Groups": "Grupos",
|
||||
"Has full access to space settings and pages.": "Tiene acceso completo a la configuración y páginas del espacio.",
|
||||
"Home": "Inicio",
|
||||
"Import pages": "Importar páginas",
|
||||
"Import pages & space settings": "Importar páginas y configuraciones del espacio",
|
||||
"Importing pages": "Importando páginas",
|
||||
"invalid invitation link": "enlace de invitación no válido",
|
||||
"Invitation signup": "Registro por invitación",
|
||||
"Invite by email": "Invitar por correo electrónico",
|
||||
"Invite members": "Invitar a miembros",
|
||||
"Invite new members": "Invitar a nuevos miembros",
|
||||
"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": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
|
||||
"Join the workspace": "Unirse al espacio de trabajo",
|
||||
"Language": "Idioma",
|
||||
"Light": "Ligero",
|
||||
"Link copied": "Enlace copiado",
|
||||
"Login": "Iniciar sesión",
|
||||
"Logout": "Cerrar sesión",
|
||||
"Manage Group": "Gestionar Grupo",
|
||||
"Manage members": "Gestionar miembros",
|
||||
"member": "miembro",
|
||||
"Member": "Miembro",
|
||||
"members": "miembros",
|
||||
"Members": "Miembros",
|
||||
"My preferences": "Mis preferencias",
|
||||
"My Profile": "Mi Perfil",
|
||||
"My profile": "Mi perfil",
|
||||
"Name": "Nombre",
|
||||
"New email": "Nuevo correo electrónico",
|
||||
"New page": "Nueva página",
|
||||
"New password": "Nueva contraseña",
|
||||
"No group found": "No se encontró grupo",
|
||||
"No page history saved yet.": "No hay historial de la página guardado aún.",
|
||||
"No pages yet": "No hay páginas todavía",
|
||||
"No results found...": "No se encontraron resultados...",
|
||||
"No user found": "No se encontró usuario",
|
||||
"Overview": "Visión general",
|
||||
"Owner": "Propietario",
|
||||
"page": "página",
|
||||
"Page deleted successfully": "Página eliminada con éxito",
|
||||
"Page history": "Historial de la página",
|
||||
"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": "Páginas",
|
||||
"pages": "páginas",
|
||||
"Password": "Contraseña",
|
||||
"Password changed successfully": "Contraseña cambiada con éxito",
|
||||
"Pending": "Pendiente",
|
||||
"Please confirm your action": "Por favor, confirme su acción",
|
||||
"Preferences": "Preferencias",
|
||||
"Print PDF": "Imprimir PDF",
|
||||
"Profile": "Perfil",
|
||||
"Recently updated": "Recientemente actualizado",
|
||||
"Remove": "Eliminar",
|
||||
"Remove group member": "Eliminar miembro del grupo",
|
||||
"Remove space member": "Eliminar miembro del espacio",
|
||||
"Restore": "Restaurar",
|
||||
"Role": "Rol",
|
||||
"Save": "Guardar",
|
||||
"Search": "Buscar",
|
||||
"Search for groups": "Buscar grupos",
|
||||
"Search for users": "Buscar usuarios",
|
||||
"Search for users and groups": "Buscar usuarios y grupos",
|
||||
"Search...": "Buscar...",
|
||||
"Select language": "Seleccionar idioma",
|
||||
"Select role": "Seleccionar rol",
|
||||
"Select role to assign to all invited members": "Seleccionar rol para asignar a todos los miembros invitados",
|
||||
"Select theme": "Seleccionar tema",
|
||||
"Send invitation": "Enviar invitación",
|
||||
"Settings": "Ajustes",
|
||||
"Setup workspace": "Configurar espacio de trabajo",
|
||||
"Sign In": "Iniciar sesión",
|
||||
"Sign Up": "Registrarse",
|
||||
"Slug": "Identificador",
|
||||
"Space": "Espacio",
|
||||
"Space description": "Descripción del espacio",
|
||||
"Space menu": "Menú de espacio",
|
||||
"Space name": "Nombre del espacio",
|
||||
"Space settings": "Configuración del espacio",
|
||||
"Space slug": "Identificador del espacio",
|
||||
"Spaces": "Espacios",
|
||||
"Spaces you belong to": "Espacios a los que perteneces",
|
||||
"No space found": "No se encontró espacio",
|
||||
"Search for spaces": "Buscar espacios",
|
||||
"Start typing to search...": "Empieza a escribir para buscar...",
|
||||
"Status": "Estado",
|
||||
"Successfully imported": "Importado con éxito",
|
||||
"Successfully restored": "Restaurado con éxito",
|
||||
"System settings": "Configuración del sistema",
|
||||
"Theme": "Tema",
|
||||
"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": "Alternar el ancho de página completa",
|
||||
"Unable to import pages. Please try again.": "No se pueden importar las páginas. Por favor, inténtelo de nuevo.",
|
||||
"untitled": "sin título",
|
||||
"Untitled": "Sin título",
|
||||
"Updated successfully": "Actualizado con éxito",
|
||||
"User": "Usuario",
|
||||
"Workspace": "Espacio de trabajo",
|
||||
"Workspace Name": "Nombre del espacio de trabajo",
|
||||
"Workspace settings": "Configuración del espacio de trabajo",
|
||||
"You can change your password here.": "Puede cambiar su contraseña aquí.",
|
||||
"Your Email": "Su correo electrónico",
|
||||
"Your import is complete.": "Su importación está completa.",
|
||||
"Your name": "Tu nombre",
|
||||
"Your Name": "Tu Nombre",
|
||||
"Your password": "Tu contraseña",
|
||||
"Your password must be a minimum of 8 characters.": "Su contraseña debe tener un mínimo de 8 caracteres.",
|
||||
"Sidebar toggle": "Alternar barra lateral",
|
||||
"Comments": "Comentarios",
|
||||
"404 page not found": "404 página no encontrada",
|
||||
"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": "Llévame de vuelta a la página de inicio",
|
||||
"Forgot password": "Olvidó la contraseña",
|
||||
"Forgot your password?": "¿Olvidó su contraseña?",
|
||||
"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": "Enviar enlace de restablecimiento",
|
||||
"Password reset": "Restablecimiento de contraseña",
|
||||
"Your new password": "Tu nueva contraseña",
|
||||
"Set password": "Establecer contraseña",
|
||||
"Write a comment": "Escribir un comentario",
|
||||
"Reply...": "Responder...",
|
||||
"Error loading comments.": "Error al cargar comentarios.",
|
||||
"No comments yet.": "No hay comentarios todavía.",
|
||||
"Edit comment": "Editar comentario",
|
||||
"Delete comment": "Eliminar comentario",
|
||||
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
|
||||
"Comment created successfully": "Comentario creado con éxito",
|
||||
"Error creating comment": "Error al crear comentario",
|
||||
"Comment updated successfully": "Comentario actualizado con éxito",
|
||||
"Failed to update comment": "No se pudo actualizar el comentario",
|
||||
"Comment deleted successfully": "Comentario eliminado con éxito",
|
||||
"Failed to delete comment": "No se pudo eliminar el comentario",
|
||||
"Comment resolved successfully": "Comentario resuelto con éxito",
|
||||
"Failed to resolve comment": "No se pudo resolver el comentario",
|
||||
"Revoke invitation": "Revocar invitación",
|
||||
"Revoke": "Revocar",
|
||||
"Don't": "No",
|
||||
"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": "Reenviar invitación",
|
||||
"Anyone with this link can join this workspace.": "Cualquiera con este enlace puede unirse a este espacio de trabajo.",
|
||||
"Invite link": "Enlace de invitación",
|
||||
"Copy": "Copiar",
|
||||
"Copied": "Copiado",
|
||||
"Select a user": "Seleccionar un usuario",
|
||||
"Select a group": "Seleccionar un grupo",
|
||||
"Export all pages and attachments in this space.": "Exportar todas las páginas y archivos adjuntos en este espacio.",
|
||||
"Delete space": "Eliminar espacio",
|
||||
"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.": "Eliminar este espacio con todas sus páginas y datos.",
|
||||
"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": "Confirmar nombre del espacio",
|
||||
"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": "Formato",
|
||||
"Include subpages": "Incluir subpáginas",
|
||||
"Include attachments": "Incluir adjuntos",
|
||||
"Select export format": "Seleccionar formato de exportación",
|
||||
"Export failed:": "Exportación fallida:",
|
||||
"export error": "error de exportación",
|
||||
"Export page": "Exportar página",
|
||||
"Export space": "Exportar espacio",
|
||||
"Export {{type}}": "Exportar {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||
"Align left": "Alinear a la izquierda",
|
||||
"Align right": "Alinear a la derecha",
|
||||
"Align center": "Alinear al centro",
|
||||
"Merge cells": "Combinar celdas",
|
||||
"Split cell": "Dividir celda",
|
||||
"Delete column": "Eliminar columna",
|
||||
"Delete row": "Eliminar fila",
|
||||
"Add left column": "Agregar columna izquierda",
|
||||
"Add right column": "Agregar columna derecha",
|
||||
"Add row above": "Agregar fila arriba",
|
||||
"Add row below": "Agregar fila debajo",
|
||||
"Delete table": "Eliminar tabla",
|
||||
"Info": "Información",
|
||||
"Success": "Satisfactorio",
|
||||
"Warning": "Advertencia",
|
||||
"Danger": "Peligro",
|
||||
"Mermaid diagram error:": "Error en diagrama de Mermaid:",
|
||||
"Invalid Mermaid diagram": "Diagrama de Mermaid no válido",
|
||||
"Double-click to edit Draw.io diagram": "Doble clic para editar el diagrama de Draw.io",
|
||||
"Exit": "Salir",
|
||||
"Save & Exit": "Guardar y Salir",
|
||||
"Double-click to edit Excalidraw diagram": "Doble clic para editar el diagrama de Excalidraw",
|
||||
"Paste link": "Pegar enlace",
|
||||
"Edit link": "Editar enlace",
|
||||
"Remove link": "Eliminar enlace",
|
||||
"Add link": "Agregar enlace",
|
||||
"Please enter a valid url": "Por favor, ingrese una URL válida",
|
||||
"Empty equation": "Ecuación vacía",
|
||||
"Invalid equation": "Ecuación no válida",
|
||||
"Color": "Color",
|
||||
"Text color": "Text color",
|
||||
"Default": "Default",
|
||||
"Blue": "Blue",
|
||||
"Green": "Green",
|
||||
"Purple": "Purple",
|
||||
"Red": "Red",
|
||||
"Yellow": "Yellow",
|
||||
"Orange": "Orange",
|
||||
"Pink": "Pink",
|
||||
"Gray": "Gray",
|
||||
"Embed link": "Embed link",
|
||||
"Invalid {{provider}} embed link": "Invalid {{provider}} embed link",
|
||||
"Embed {{provider}}": "Embed {{provider}}",
|
||||
"Enter {{provider}} link to embed": "Enter {{provider}} link to embed",
|
||||
"Bold": "Bold",
|
||||
"Italic": "Italic",
|
||||
"Underline": "Underline",
|
||||
"Strike": "Strike",
|
||||
"Code": "Code",
|
||||
"Comment": "Comment",
|
||||
"Text": "Text",
|
||||
"Heading 1": "Heading 1",
|
||||
"Heading 2": "Heading 2",
|
||||
"Heading 3": "Heading 3",
|
||||
"To-do List": "To-do List",
|
||||
"Bullet List": "Bullet List",
|
||||
"Numbered List": "Numbered List",
|
||||
"Blockquote": "Blockquote",
|
||||
"Just start typing with plain text.": "Just start typing with plain text.",
|
||||
"Track tasks with a to-do list.": "Track tasks with a to-do list.",
|
||||
"Big section heading.": "Big section heading.",
|
||||
"Medium section heading.": "Medium section heading.",
|
||||
"Small section heading.": "Small section heading.",
|
||||
"Create a simple bullet list.": "Create a simple bullet list.",
|
||||
"Create a list with numbering.": "Create a list with numbering.",
|
||||
"Create block quote.": "Create block quote.",
|
||||
"Insert code snippet.": "Insert code snippet.",
|
||||
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
||||
"Upload any image from your device.": "Upload any image from your device.",
|
||||
"Upload any video from your device.": "Upload any video from your device.",
|
||||
"Upload any file from your device.": "Upload any file from your device.",
|
||||
"Table": "Table",
|
||||
"Insert a table.": "Insert a table.",
|
||||
"Insert collapsible block.": "Insert collapsible block.",
|
||||
"Video": "Video",
|
||||
"Divider": "Divider",
|
||||
"Quote": "Quote",
|
||||
"Image": "Image",
|
||||
"File attachment": "File attachment",
|
||||
"Toggle block": "Toggle block",
|
||||
"Callout": "Callout",
|
||||
"Insert callout notice.": "Insert callout notice.",
|
||||
"Math inline": "Math inline",
|
||||
"Insert inline math equation.": "Insert inline math equation.",
|
||||
"Math block": "Math block",
|
||||
"Insert math equation": "Insert math equation",
|
||||
"Mermaid diagram": "Mermaid diagram",
|
||||
"Insert mermaid diagram": "Insert mermaid diagram",
|
||||
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
||||
"Insert current date": "Insert current date",
|
||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||
"Multiple": "Multiple",
|
||||
"Heading {{level}}": "Heading {{level}}",
|
||||
"Toggle title": "Toggle title",
|
||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||
"Names do not match": "Names do not match",
|
||||
"Today, {{time}}": "Today, {{time}}",
|
||||
"Yesterday, {{time}}": "Yesterday, {{time}}"
|
||||
"Text color": "Color del texto",
|
||||
"Default": "Predeterminado",
|
||||
"Blue": "Azul",
|
||||
"Green": "Verde",
|
||||
"Purple": "Morado",
|
||||
"Red": "Rojo",
|
||||
"Yellow": "Amarillo",
|
||||
"Orange": "Naranja",
|
||||
"Pink": "Rosa",
|
||||
"Gray": "Gris",
|
||||
"Embed link": "Enlace adjunto",
|
||||
"Invalid {{provider}} embed link": "Enlace incrustado {{provider}} no válido",
|
||||
"Embed {{provider}}": "Incrustar {{provider}}",
|
||||
"Enter {{provider}} link to embed": "Introduzca el enlace de {{provider}} para incrustar",
|
||||
"Bold": "Negrita",
|
||||
"Italic": "Cursiva",
|
||||
"Underline": "Subrayar",
|
||||
"Strike": "Tachar",
|
||||
"Code": "Código",
|
||||
"Comment": "Comentario",
|
||||
"Text": "Texto",
|
||||
"Heading 1": "Encabezado 1",
|
||||
"Heading 2": "Encabezado 2",
|
||||
"Heading 3": "Encabezado 3",
|
||||
"To-do List": "Lista de cosas por hacer",
|
||||
"Bullet List": "Lista con viñetas",
|
||||
"Numbered List": "Lista numerada",
|
||||
"Blockquote": "Cita en bloque",
|
||||
"Just start typing with plain text.": "Simplemente comienza a escribir con texto sin formato.",
|
||||
"Track tasks with a to-do list.": "Administra tareas con una lista de tareas pendientes.",
|
||||
"Big section heading.": "Gran encabezado de sección.",
|
||||
"Medium section heading.": "Encabezado de sección mediano.",
|
||||
"Small section heading.": "Pequeño encabezado de sección.",
|
||||
"Create a simple bullet list.": "Crear una lista con viñetas simple.",
|
||||
"Create a list with numbering.": "Crear una lista con numeración.",
|
||||
"Create block quote.": "Crear una cita en bloque.",
|
||||
"Insert code snippet.": "Insertar fragmento de código.",
|
||||
"Insert horizontal rule divider": "Insertar regla horizontal",
|
||||
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
||||
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
||||
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
||||
"Table": "Tabla",
|
||||
"Insert a table.": "Insertar una tabla.",
|
||||
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||
"Video": "Vídeo",
|
||||
"Divider": "Divisor",
|
||||
"Quote": "Cita",
|
||||
"Image": "Imagen",
|
||||
"File attachment": "Adjunto de archivo",
|
||||
"Toggle block": "Alternar bloque",
|
||||
"Callout": "Aviso",
|
||||
"Insert callout notice.": "Insertar aviso de llamada.",
|
||||
"Math inline": "Matemáticas en línea",
|
||||
"Insert inline math equation.": "Insertar ecuación matemática en línea.",
|
||||
"Math block": "Bloque de matemáticas",
|
||||
"Insert math equation": "Insertar ecuación matemática",
|
||||
"Mermaid diagram": "Diagrama de Mermaid",
|
||||
"Insert mermaid diagram": "Insertar diagrama de Mermaid",
|
||||
"Insert and design Drawio diagrams": "Insertar y diseñar diagramas Drawio",
|
||||
"Insert current date": "Insertar fecha actual",
|
||||
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
|
||||
"Multiple": "Múltiple",
|
||||
"Heading {{level}}": "Encabezado {{level}}",
|
||||
"Toggle title": "Alternar título",
|
||||
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||
"Names do not match": "Los nombres no coinciden",
|
||||
"Today, {{time}}": "Hoy, {{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 Spaces from "@/pages/settings/space/spaces.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 SpaceHome from "@/pages/space/space-home.tsx";
|
||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
@@ -30,35 +22,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function App() {
|
||||
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 (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
size="sm"
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
||||
import { ITokens } from "../types/auth.types";
|
||||
|
||||
const cookieStorage = createJSONStorage<ITokens>(() => {
|
||||
const cookieStorage = createJSONStorage<any>(() => {
|
||||
return {
|
||||
getItem: () => Cookies.get("authTokens"),
|
||||
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",
|
||||
null,
|
||||
cookieStorage,
|
||||
|
||||
@@ -2,14 +2,7 @@ import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import { IPasswordReset } from "@/features/auth/types/auth.types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
PasswordInput,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useState } from "react";
|
||||
import {
|
||||
forgotPassword,
|
||||
login,
|
||||
logout,
|
||||
passwordReset,
|
||||
setupWorkspace,
|
||||
verifyUserToken,
|
||||
} from "@/features/auth/services/auth-service";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
IForgotPassword,
|
||||
@@ -20,31 +20,26 @@ import {
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.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 { useQueryClient } from "@tanstack/react-query";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await login(data);
|
||||
await login(data);
|
||||
setIsLoading(false);
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setIsLoading(false);
|
||||
console.log(err);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: "red",
|
||||
@@ -56,11 +51,8 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await acceptInvitation(data);
|
||||
await acceptInvitation(data);
|
||||
setIsLoading(false);
|
||||
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
@@ -77,9 +69,6 @@ export default function useAuth() {
|
||||
try {
|
||||
const res = await setupWorkspace(data);
|
||||
setIsLoading(false);
|
||||
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
@@ -94,14 +83,11 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await passwordReset(data);
|
||||
await passwordReset(data);
|
||||
setIsLoading(false);
|
||||
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: "Password reset was successful",
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
} catch (err) {
|
||||
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 () => {
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
Cookies.remove("authTokens");
|
||||
queryClient.clear();
|
||||
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
|
||||
setCurrentUser(RESET);
|
||||
await logout();
|
||||
window.location.replace(APP_ROUTE.AUTH.LOGIN);
|
||||
};
|
||||
|
||||
const handleForgotPassword = async (data: IForgotPassword) => {
|
||||
@@ -182,12 +144,10 @@ export default function useAuth() {
|
||||
signIn: handleSignIn,
|
||||
invitationSignup: handleInvitationSignUp,
|
||||
setupWorkspace: handleSetupWorkspace,
|
||||
isAuthenticated: handleIsAuthenticated,
|
||||
forgotPassword: handleForgotPassword,
|
||||
passwordReset: handlePasswordReset,
|
||||
verifyUserToken: handleVerifyUserToken,
|
||||
logout: handleLogout,
|
||||
hasTokens,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
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 useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
|
||||
export function useRedirectIfAuthenticated() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data, isLoading } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const validAuth = await isAuthenticated();
|
||||
if (validAuth) {
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [isAuthenticated]);
|
||||
if (data && data?.user) {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
}, [isLoading, data]);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { verifyUserToken } from "../services/auth-service";
|
||||
import { IVerifyUserToken } from "../types/auth.types";
|
||||
import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||
|
||||
export function useVerifyUserTokenQuery(
|
||||
verify: IVerifyUserToken,
|
||||
): UseQueryResult<any, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["verify-token", verify],
|
||||
queryFn: () => verifyUserToken(verify),
|
||||
enabled: !!verify.token,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
verify: IVerifyUserToken,
|
||||
): UseQueryResult<any, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["verify-token", verify],
|
||||
queryFn: () => verifyUserToken(verify),
|
||||
enabled: !!verify.token,
|
||||
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 {
|
||||
IChangePassword,
|
||||
ICollabToken,
|
||||
IForgotPassword,
|
||||
ILogin,
|
||||
IPasswordReset,
|
||||
IRegister,
|
||||
ISetupWorkspace,
|
||||
ITokenResponse,
|
||||
IVerifyUserToken,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
|
||||
export async function login(data: ILogin): Promise<ITokenResponse> {
|
||||
const req = await api.post<ITokenResponse>("/auth/login", data);
|
||||
return req.data;
|
||||
export async function login(data: ILogin): Promise<void> {
|
||||
await api.post<void>("/auth/login", data);
|
||||
}
|
||||
|
||||
/*
|
||||
export async function register(data: IRegister): Promise<ITokenResponse> {
|
||||
const req = await api.post<ITokenResponse>("/auth/register", data);
|
||||
return req.data;
|
||||
}*/
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post<void>("/auth/logout");
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
data: IChangePassword
|
||||
data: IChangePassword,
|
||||
): Promise<IChangePassword> {
|
||||
const req = await api.post<IChangePassword>("/auth/change-password", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupWorkspace(
|
||||
data: ISetupWorkspace
|
||||
): Promise<ITokenResponse> {
|
||||
const req = await api.post<ITokenResponse>("/auth/setup", data);
|
||||
data: ISetupWorkspace,
|
||||
): Promise<any> {
|
||||
const req = await api.post<any>("/auth/setup", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
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(
|
||||
data: IPasswordReset
|
||||
): Promise<ITokenResponse> {
|
||||
const req = await api.post<any>("/auth/password-reset", data);
|
||||
return req.data;
|
||||
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||
await api.post<void>("/auth/password-reset", data);
|
||||
}
|
||||
|
||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ITokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface ITokenResponse {
|
||||
tokens: ITokens;
|
||||
}
|
||||
|
||||
export interface IChangePassword {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
@@ -43,3 +34,7 @@ export interface IVerifyUserToken {
|
||||
token: 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 { ColorSelector } from "./color-selector";
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||
import {
|
||||
draftCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
@@ -117,6 +118,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
},
|
||||
@@ -124,6 +126,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
@@ -135,6 +138,20 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
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}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -170,6 +190,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
isOpen={isColorSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
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 { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import classes from './code-block.module.css';
|
||||
import React from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import classes from "./code-block.module.css";
|
||||
import React from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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) {
|
||||
@@ -16,7 +16,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, extension, editor, getPos } = props;
|
||||
const { language } = node.attrs;
|
||||
const [languageValue, setLanguageValue] = useState<string | null>(
|
||||
language || null
|
||||
language || null,
|
||||
);
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
@@ -31,9 +31,9 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
setIsSelected(isNodeSelected);
|
||||
};
|
||||
|
||||
editor.on('selectionUpdate', updateSelection);
|
||||
editor.on("selectionUpdate", updateSelection);
|
||||
return () => {
|
||||
editor.off('selectionUpdate', updateSelection);
|
||||
editor.off("selectionUpdate", updateSelection);
|
||||
};
|
||||
}, [editor, getPos(), node.nodeSize]);
|
||||
|
||||
@@ -46,7 +46,11 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="codeBlock">
|
||||
<Group justify="flex-end" contentEditable={false}>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
contentEditable={false}
|
||||
className={classes.menuGroup}
|
||||
>
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
@@ -54,7 +58,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: '130px' }}
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
disabled={!editor.isEditable}
|
||||
/>
|
||||
@@ -62,12 +66,12 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
<CopyButton value={node?.textContent} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t('Copied') : t('Copy')}
|
||||
label={copied ? t("Copied") : t("Copy")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
>
|
||||
@@ -81,15 +85,15 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
<pre
|
||||
spellCheck="false"
|
||||
hidden={
|
||||
((language === 'mermaid' && !editor.isEditable) ||
|
||||
(language === 'mermaid' && !isSelected)) &&
|
||||
((language === "mermaid" && !editor.isEditable) ||
|
||||
(language === "mermaid" && !isSelected)) &&
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
{language === 'mermaid' && (
|
||||
{language === "mermaid" && (
|
||||
<Suspense fallback={null}>
|
||||
<MermaidView props={props} />
|
||||
</Suspense>
|
||||
|
||||
@@ -16,3 +16,9 @@
|
||||
align-items: 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 { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
|
||||
export const handleFilePaste = (
|
||||
export const handlePaste = (
|
||||
view: EditorView,
|
||||
event: ClipboardEvent,
|
||||
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) {
|
||||
event.preventDefault();
|
||||
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";
|
||||
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./link.module.css";
|
||||
|
||||
export type LinkPreviewPanelProps = {
|
||||
url: string;
|
||||
@@ -31,12 +32,7 @@ export const LinkPreviewPanel = ({
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
inherit
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
className={classes.link}
|
||||
>
|
||||
{url}
|
||||
</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,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -64,8 +65,11 @@ import clojure from "highlight.js/lib/languages/clojure";
|
||||
import fortran from "highlight.js/lib/languages/fortran";
|
||||
import haskell from "highlight.js/lib/languages/haskell";
|
||||
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 i18n from "i18next";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -133,6 +137,23 @@ export const mainExtensions = [
|
||||
class: "comment-mark",
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
allowSpaces: true,
|
||||
items: () => {
|
||||
return [];
|
||||
},
|
||||
// @ts-ignore
|
||||
render: mentionRenderItems,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: false,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
import { useAtom } from "jotai";
|
||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
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 {
|
||||
handleFileDrop,
|
||||
handleFilePaste,
|
||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
||||
handlePaste,
|
||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -53,7 +53,6 @@ export default function PageEditor({
|
||||
editable,
|
||||
content,
|
||||
}: PageEditorProps) {
|
||||
const [token] = useAtom(authTokensAtom);
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
@@ -68,6 +67,7 @@ export default function PageEditor({
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const documentName = `page.${pageId}`;
|
||||
const { data } = useCollabToken();
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||
@@ -77,14 +77,14 @@ export default function PageEditor({
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
}, [pageId, ydoc, data?.token]);
|
||||
|
||||
const remoteProvider = useMemo(() => {
|
||||
const provider = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: token?.accessToken,
|
||||
token: data?.token,
|
||||
connect: false,
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
@@ -102,7 +102,7 @@ export default function PageEditor({
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [ydoc, pageId, token?.accessToken]);
|
||||
}, [ydoc, pageId, data?.token]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
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) =>
|
||||
handleFileDrop(view, event, moved, pageId),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
margin: 4px;
|
||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||
border-radius: var(--mantine-radius-default);
|
||||
tab-size: 4;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
|
||||
@@ -56,8 +56,14 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: light-dark(#207af1, #587da9);
|
||||
/*font-weight: bold;*/
|
||||
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
|
||||
@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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -9,3 +9,5 @@
|
||||
@import "./media.css";
|
||||
@import "./code.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 { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
|
||||
export default function GroupList() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetGroupsQuery();
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading } = useGetGroupsQuery({ page });
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table.ScrollContainer minWidth={400}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Group")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Group")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group: IGroup, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{formatMemberCount(group.memberCount, t)}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group: IGroup, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{formatMemberCount(group.memberCount, t)}
|
||||
</Anchor>
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,18 +4,20 @@ import {
|
||||
useRemoveGroupMemberMutation,
|
||||
} from "@/features/group/queries/group-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
|
||||
export default function GroupMembersList() {
|
||||
const { t } = useTranslation();
|
||||
const { groupId } = useParams();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
|
||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
@@ -45,67 +47,71 @@ export default function GroupMembersList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("User")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("User")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<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(user.id)}>
|
||||
{t("Remove group member")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<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(user.id)}>
|
||||
{t("Remove group 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,14 +14,14 @@ interface MultiUserSelectProps {
|
||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
option,
|
||||
}) => (
|
||||
<Group gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={option?.["avatarUrl"]}
|
||||
name={option.label}
|
||||
size={36}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
<Text size="xs" opacity={0.5}>
|
||||
{option?.["email"]}
|
||||
</Text>
|
||||
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { IGroup } from '@/features/group/types/group.types';
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import { IGroup } from "@/features/group/types/group.types";
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
@@ -14,22 +15,24 @@ import {
|
||||
getGroups,
|
||||
removeGroupMember,
|
||||
updateGroup,
|
||||
} from '@/features/group/services/group-service';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { QueryParams } from '@/lib/types.ts';
|
||||
} from "@/features/group/services/group-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
|
||||
export function useGetGroupsQuery(
|
||||
params?: QueryParams
|
||||
): UseQueryResult<any, Error> {
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<IGroup>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['groups', params],
|
||||
queryKey: ["groups", params],
|
||||
queryFn: () => getGroups(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['group', groupId],
|
||||
queryKey: ["group", groupId],
|
||||
queryFn: () => getGroupById(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
@@ -42,13 +45,13 @@ export function useCreateGroupMutation() {
|
||||
mutationFn: (data) => createGroup(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['groups'],
|
||||
queryKey: ["groups"],
|
||||
});
|
||||
|
||||
notifications.show({ message: 'Group created successfully' });
|
||||
notifications.show({ message: "Group created successfully" });
|
||||
},
|
||||
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>>({
|
||||
mutationFn: (data) => updateGroup(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Group updated successfully' });
|
||||
notifications.show({ message: "Group updated successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['group', variables.groupId],
|
||||
queryKey: ["group", variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -77,28 +80,25 @@ export function useDeleteGroupMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Group deleted successfully' });
|
||||
|
||||
const groups = queryClient.getQueryData(['groups']) as any;
|
||||
if (groups) {
|
||||
groups.items = groups.items?.filter(
|
||||
(group: IGroup) => group.id !== variables
|
||||
);
|
||||
queryClient.setQueryData(['groups'], groups);
|
||||
}
|
||||
notifications.show({ message: "Group deleted successfully" });
|
||||
queryClient.refetchQueries({ queryKey: ["groups"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembersQuery(groupId: string) {
|
||||
export function useGroupMembersQuery(
|
||||
groupId: string,
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<IUser>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['groupMembers', groupId],
|
||||
queryFn: () => getGroupMembers(groupId),
|
||||
queryKey: ["groupMembers", groupId, params],
|
||||
queryFn: () => getGroupMembers(groupId, params),
|
||||
enabled: !!groupId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,15 +108,15 @@ export function useAddGroupMemberMutation() {
|
||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||
mutationFn: (data) => addGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Added successfully' });
|
||||
notifications.show({ message: "Added successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['groupMembers', variables.groupId],
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: 'Failed to add group members',
|
||||
color: 'red',
|
||||
message: "Failed to add group members",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -135,14 +135,14 @@ export function useRemoveGroupMemberMutation() {
|
||||
>({
|
||||
mutationFn: (data) => removeGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Removed successfully' });
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['groupMembers', variables.groupId],
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import api from "@/lib/api-client";
|
||||
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> {
|
||||
// TODO: returns paginated. Fix type
|
||||
const req = await api.post<any>("/groups", params);
|
||||
export async function getGroups(
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IGroup>> {
|
||||
const req = await api.post("/groups", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -27,8 +29,11 @@ export async function deleteGroup(data: { groupId: string }): Promise<void> {
|
||||
await api.post("/groups/delete", data);
|
||||
}
|
||||
|
||||
export async function getGroupMembers(groupId: string) {
|
||||
const req = await api.post("/groups/members", { groupId });
|
||||
export async function getGroupMembers(
|
||||
groupId: string,
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IUser>> {
|
||||
const req = await api.post("/groups/members", { groupId, params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ export function useSearchSuggestionsQuery(
|
||||
params: SearchSuggestionParams,
|
||||
): UseQueryResult<ISuggestionResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["search-suggestion", params],
|
||||
queryKey: ["search-suggestion", params.query],
|
||||
staleTime: 60 * 1000, // 1min
|
||||
queryFn: () => searchSuggestions(params),
|
||||
enabled: !!params.query,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Group, Center, Text } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
@@ -33,13 +34,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>
|
||||
{page?.icon ? (
|
||||
<span style={{ fontSize: "20px" }}>{page.icon}</span>
|
||||
) : (
|
||||
<IconFileDescription size={20} />
|
||||
)}
|
||||
</Center>
|
||||
<Center>{getPageIcon(page?.icon)}</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{page.title}</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface IPageSearch {
|
||||
id: string;
|
||||
@@ -20,11 +21,15 @@ export interface SearchSuggestionParams {
|
||||
query: string;
|
||||
includeUsers?: boolean;
|
||||
includeGroups?: boolean;
|
||||
includePages?: boolean;
|
||||
spaceId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ISuggestionResult {
|
||||
users?: Partial<IUser[]>;
|
||||
groups?: Partial<IGroup[]>;
|
||||
pages?: Partial<IPage[]>;
|
||||
}
|
||||
|
||||
export interface IPageSearchParams {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MultiMemberSelectProps {
|
||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
option,
|
||||
}) => (
|
||||
<Group gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{option["type"] === "user" && (
|
||||
<CustomAvatar
|
||||
avatarUrl={option["avatarUrl"]}
|
||||
@@ -25,7 +25,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
)}
|
||||
{option["type"] === "group" && <IconGroupCircle />}
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,10 @@ interface SpaceSelectProps {
|
||||
}
|
||||
|
||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||
<Group gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SwitchSpaceProps {
|
||||
|
||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
const navigate = useNavigate();
|
||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value) {
|
||||
@@ -27,6 +28,8 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
opened={opened}
|
||||
onChange={toggle}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
@@ -35,6 +38,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronDown size={18} />}
|
||||
color="gray"
|
||||
onClick={open}
|
||||
>
|
||||
<Avatar
|
||||
size={20}
|
||||
|
||||
@@ -5,10 +5,12 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
|
||||
export default function SpaceList() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetSpacesQuery();
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading } = useGetSpacesQuery({ page });
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||
|
||||
@@ -19,50 +21,57 @@ export default function SpaceList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table.ScrollContainer minWidth={400}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Space")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Space")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((space, index) => (
|
||||
<Table.Tr
|
||||
key={index}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleClick(space.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{space.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatMemberCount(space.memberCount, t)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Table.Tbody>
|
||||
{data?.items.map((space, index) => (
|
||||
<Table.Tr
|
||||
key={index}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleClick(space.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{space.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatMemberCount(space.memberCount, t)}
|
||||
</Text>
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedSpaceId && (
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
} from "@/features/space/types/space-role-data.ts";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
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";
|
||||
|
||||
@@ -30,8 +33,12 @@ export default function SpaceMembersList({
|
||||
readOnly,
|
||||
}: SpaceMembersProps) {
|
||||
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 changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
||||
|
||||
@@ -98,94 +105,102 @@ export default function SpaceMembersList({
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing={8}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Member")}</Table.Th>
|
||||
<Table.Th>{t("Role")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing={8}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Member")}</Table.Th>
|
||||
<Table.Th>{t("Role")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((member, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
{member.type === "user" && (
|
||||
<CustomAvatar
|
||||
avatarUrl={member?.avatarUrl}
|
||||
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.Tbody>
|
||||
{data?.items.map((member, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{member.type === "user" && (
|
||||
<CustomAvatar
|
||||
avatarUrl={member?.avatarUrl}
|
||||
name={member.name}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{member.type === "group" && <IconGroupCircle />}
|
||||
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{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 {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
IAddSpaceMember,
|
||||
IChangeSpaceMemberRole,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
ISpaceMember,
|
||||
} from '@/features/space/types/space.types';
|
||||
} from "@/features/space/types/space.types";
|
||||
import {
|
||||
addSpaceMember,
|
||||
changeMemberRole,
|
||||
@@ -21,22 +22,23 @@ import {
|
||||
createSpace,
|
||||
updateSpace,
|
||||
deleteSpace,
|
||||
} from '@/features/space/services/space-service.ts';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IPagination, QueryParams } from '@/lib/types.ts';
|
||||
} from "@/features/space/services/space-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export function useGetSpacesQuery(
|
||||
params?: QueryParams
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['spaces', params],
|
||||
queryKey: ["spaces", params],
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['space', spaceId],
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -50,22 +52,22 @@ export function useCreateSpaceMutation() {
|
||||
mutationFn: (data) => createSpace(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['spaces'],
|
||||
queryKey: ["spaces"],
|
||||
});
|
||||
notifications.show({ message: 'Space created successfully' });
|
||||
notifications.show({ message: "Space created successfully" });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSpaceBySlugQuery(
|
||||
spaceId: string
|
||||
spaceId: string,
|
||||
): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['space', spaceId],
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -78,25 +80,25 @@ export function useUpdateSpaceMutation() {
|
||||
return useMutation<ISpace, Error, Partial<ISpace>>({
|
||||
mutationFn: (data) => updateSpace(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Space updated successfully' });
|
||||
notifications.show({ message: "Space updated successfully" });
|
||||
|
||||
const space = queryClient.getQueryData([
|
||||
'space',
|
||||
"space",
|
||||
variables.spaceId,
|
||||
]) as ISpace;
|
||||
if (space) {
|
||||
const updatedSpace = { ...space, ...data };
|
||||
queryClient.setQueryData(['space', variables.spaceId], updatedSpace);
|
||||
queryClient.setQueryData(['space', data.slug], updatedSpace);
|
||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['spaces'],
|
||||
queryKey: ["spaces"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -107,37 +109,39 @@ export function useDeleteSpaceMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Space deleted successfully' });
|
||||
notifications.show({ message: "Space deleted successfully" });
|
||||
|
||||
if (variables.slug) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['space', variables.slug],
|
||||
queryKey: ["space", variables.slug],
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
const spaces = queryClient.getQueryData(['spaces']) as any;
|
||||
const spaces = queryClient.getQueryData(["spaces"]) as any;
|
||||
if (spaces) {
|
||||
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) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpaceMembersQuery(
|
||||
spaceId: string
|
||||
spaceId: string,
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<ISpaceMember>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['spaceMembers', spaceId],
|
||||
queryFn: () => getSpaceMembers(spaceId),
|
||||
queryKey: ["spaceMembers", spaceId, params],
|
||||
queryFn: () => getSpaceMembers(spaceId, params),
|
||||
enabled: !!spaceId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,14 +151,14 @@ export function useAddSpaceMemberMutation() {
|
||||
return useMutation<void, Error, IAddSpaceMember>({
|
||||
mutationFn: (data) => addSpaceMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Members added successfully' });
|
||||
notifications.show({ message: "Members added successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['spaceMembers', variables.spaceId],
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -165,14 +169,14 @@ export function useRemoveSpaceMemberMutation() {
|
||||
return useMutation<void, Error, IRemoveSpaceMember>({
|
||||
mutationFn: (data) => removeSpaceMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: 'Removed successfully' });
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ['spaceMembers', variables.spaceId],
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -183,15 +187,15 @@ export function useChangeSpaceMemberRoleMutation() {
|
||||
return useMutation<void, Error, IChangeSpaceMemberRole>({
|
||||
mutationFn: (data) => changeMemberRole(data),
|
||||
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
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ['spaceMembers', variables.spaceId],
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
IExportSpaceParams,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
ISpaceMember,
|
||||
} from "@/features/space/types/space.types";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
export async function getSpaces(
|
||||
params?: QueryParams
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<ISpace>> {
|
||||
const req = await api.post("/spaces", params);
|
||||
return req.data;
|
||||
@@ -37,9 +38,10 @@ export async function deleteSpace(spaceId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getSpaceMembers(
|
||||
spaceId: string
|
||||
): Promise<IPagination<IUser>> {
|
||||
const req = await api.post<any>("/spaces/members", { spaceId });
|
||||
spaceId: string,
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<ISpaceMember>> {
|
||||
const req = await api.post<any>("/spaces/members", { spaceId, ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -48,13 +50,13 @@ export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
|
||||
}
|
||||
|
||||
export async function removeSpaceMember(
|
||||
data: IRemoveSpaceMember
|
||||
data: IRemoveSpaceMember,
|
||||
): Promise<void> {
|
||||
await api.post("/spaces/members/remove", data);
|
||||
}
|
||||
|
||||
export async function changeMemberRole(
|
||||
data: IChangeSpaceMemberRole
|
||||
data: IChangeSpaceMemberRole,
|
||||
): Promise<void> {
|
||||
await api.post("/spaces/members/change-role", data);
|
||||
}
|
||||
|
||||
@@ -41,13 +41,18 @@ function LanguageSwitcher() {
|
||||
<Select
|
||||
label={t("Select language")}
|
||||
data={[
|
||||
{ value: "en-US", label: "English (United States)" },
|
||||
{ value: "de-DE", label: "Deutsch (Germany)" },
|
||||
{ value: "fr-FR", label: "Français (France)" },
|
||||
{ value: "pt-BR", label: "Português (Brazilian)" },
|
||||
{ value: "en-US", label: "English (US)" },
|
||||
{ value: "de-DE", label: "Deutsch (German)" },
|
||||
{ value: "fr-FR", label: "Français (French)" },
|
||||
{ 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={language}
|
||||
value={language || 'en-US'}
|
||||
onChange={handleChange}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
|
||||
@@ -3,11 +3,42 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import React, { useEffect } from "react";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
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) {
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
const { data, isLoading, error } = useCurrentUser();
|
||||
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(() => {
|
||||
if (data && data.user && data.workspace) {
|
||||
|
||||
+50
-44
@@ -1,19 +1,22 @@
|
||||
import {Group, Table, Avatar, Text, Alert} from "@mantine/core";
|
||||
import {useWorkspaceInvitationsQuery} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import React from "react";
|
||||
import {getUserRoleLabel} from "@/features/workspace/types/user-role-data.ts";
|
||||
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import React, { useState } from "react";
|
||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||
import {IconInfoCircle} from "@tabler/icons-react";
|
||||
import {formattedDate, timeAgo} from "@/lib/time.ts";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { timeAgo } from "@/lib/time.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
|
||||
export default function WorkspaceInvitesTable() {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||
page,
|
||||
limit: 100,
|
||||
});
|
||||
const {isAdmin} = useUserRole();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,47 +26,50 @@ export default function WorkspaceInvitesTable() {
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Email")}</Table.Th>
|
||||
<Table.Th>{t("Role")}</Table.Th>
|
||||
<Table.Th>{t("Date")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Email")}</Table.Th>
|
||||
<Table.Th>{t("Role")}</Table.Th>
|
||||
<Table.Th>{t("Date")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((invitation, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar name={invitation.email} color="initials"/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{invitation.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Tbody>
|
||||
{data?.items.map((invitation, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar name={invitation.email} color="initials" />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{invitation.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</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>
|
||||
{isAdmin && (
|
||||
<InviteActionMenu invitationId={invitation.id}/>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</>
|
||||
<Table.Td>
|
||||
{isAdmin && <InviteActionMenu invitationId={invitation.id} />}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+48
-24
@@ -1,10 +1,10 @@
|
||||
import {Group, Table, Text, Badge} from "@mantine/core";
|
||||
import { Group, Table, Text, Badge } from "@mantine/core";
|
||||
import {
|
||||
useChangeMemberRoleMutation,
|
||||
useWorkspaceMembersQuery,
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
|
||||
import {
|
||||
getUserRoleLabel,
|
||||
@@ -13,12 +13,21 @@ import {
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { UserRole } from "@/lib/types.ts";
|
||||
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() {
|
||||
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 {isAdmin, isOwner} = useUserRole();
|
||||
const { isAdmin, isOwner } = useUserRole();
|
||||
|
||||
const assignableUserRoles = isOwner
|
||||
? userRoleData
|
||||
@@ -43,25 +52,29 @@ export default function WorkspaceMembersTable() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("User")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("Role")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("User")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("Role")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user, index) => (
|
||||
<Table.Tbody>
|
||||
{data?.items.length > 0 ? (
|
||||
data?.items.map((user, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
@@ -84,10 +97,21 @@ export default function WorkspaceMembersTable() {
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={3} />
|
||||
)}
|
||||
</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 {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
IInvitation,
|
||||
IWorkspace,
|
||||
} from "@/features/workspace/types/workspace.types.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
|
||||
export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
|
||||
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({
|
||||
queryKey: ["workspaceMembers", params],
|
||||
queryFn: () => getWorkspaceMembers(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +58,6 @@ export function useChangeMemberRoleMutation() {
|
||||
return useMutation<any, Error, any>({
|
||||
mutationFn: (data) => changeMemberRole(data),
|
||||
onSuccess: (data, variables) => {
|
||||
// TODO: change in cache instead
|
||||
notifications.show({ message: "Member role updated successfully" });
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["workspaceMembers"],
|
||||
@@ -72,6 +76,7 @@ export function useWorkspaceInvitationsQuery(
|
||||
return useQuery({
|
||||
queryKey: ["invitations", params],
|
||||
queryFn: () => getPendingInvitations(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +87,6 @@ export function useCreateInvitationMutation() {
|
||||
mutationFn: (data) => createInvitation(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Invitation sent" });
|
||||
// TODO: mutate cache
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["invitations"],
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
IAcceptInvite,
|
||||
} from "../types/workspace.types";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { ITokenResponse } from "@/features/auth/types/auth.types.ts";
|
||||
|
||||
export async function getWorkspace(): Promise<IWorkspace> {
|
||||
const req = await api.post<IWorkspace>("/workspace/info");
|
||||
@@ -19,7 +18,6 @@ export async function getWorkspacePublicData(): Promise<IWorkspace> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
// Todo: fix all paginated types
|
||||
export async function getWorkspaceMembers(
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IUser>> {
|
||||
@@ -51,11 +49,8 @@ export async function createInvitation(data: ICreateInvite) {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(
|
||||
data: IAcceptInvite,
|
||||
): Promise<ITokenResponse> {
|
||||
const req = await api.post("/workspace/invites/accept", data);
|
||||
return req.data;
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
|
||||
await api.post<void>("/workspace/invites/accept", 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 Cookies from "js-cookie";
|
||||
import Routes from "@/lib/app-route.ts";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: "/api",
|
||||
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(
|
||||
(response) => {
|
||||
// we need the response headers for these endpoints
|
||||
@@ -45,11 +22,14 @@ api.interceptors.response.use(
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
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
|
||||
Cookies.remove("authTokens");
|
||||
redirectToLogin();
|
||||
break;
|
||||
}
|
||||
case 403:
|
||||
// Handle forbidden error
|
||||
break;
|
||||
@@ -61,10 +41,8 @@ api.interceptors.response.use(
|
||||
.includes("workspace not found")
|
||||
) {
|
||||
console.log("workspace not found");
|
||||
Cookies.remove("authTokens");
|
||||
|
||||
if (window.location.pathname != Routes.AUTH.SETUP) {
|
||||
window.location.href = Routes.AUTH.SETUP;
|
||||
if (window.location.pathname != APP_ROUTE.AUTH.SETUP) {
|
||||
window.location.href = APP_ROUTE.AUTH.SETUP;
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -76,15 +54,19 @@ api.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function redirectToLogin() {
|
||||
if (
|
||||
window.location.pathname != Routes.AUTH.LOGIN &&
|
||||
window.location.pathname != Routes.AUTH.SIGNUP
|
||||
) {
|
||||
window.location.href = Routes.AUTH.LOGIN;
|
||||
const exemptPaths = [
|
||||
APP_ROUTE.AUTH.LOGIN,
|
||||
APP_ROUTE.AUTH.SIGNUP,
|
||||
APP_ROUTE.AUTH.FORGOT_PASSWORD,
|
||||
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,62 @@
|
||||
import bytes from "bytes";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CONFIG?: Record<string, string>;
|
||||
}
|
||||
interface Window {
|
||||
CONFIG?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppName(): string{
|
||||
return 'Docmost';
|
||||
export function getAppName(): string {
|
||||
return "Docmost";
|
||||
}
|
||||
|
||||
export function getAppUrl(): string {
|
||||
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
|
||||
|
||||
// if (import.meta.env.DEV) {
|
||||
// return appUrl || "http://localhost:3000";
|
||||
//}
|
||||
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
}
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
return getAppUrl() + '/api';
|
||||
return getAppUrl() + "/api";
|
||||
}
|
||||
|
||||
export function getCollaborationUrl(): string {
|
||||
const COLLAB_PATH = '/collab';
|
||||
const COLLAB_PATH = "/collab";
|
||||
|
||||
let url = getAppUrl();
|
||||
if (import.meta.env.DEV) {
|
||||
url = process.env.APP_URL;
|
||||
}
|
||||
let url = getAppUrl();
|
||||
if (import.meta.env.DEV) {
|
||||
url = process.env.APP_URL;
|
||||
}
|
||||
|
||||
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
|
||||
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
|
||||
const wsProtocol = url.startsWith("https") ? "wss" : "ws";
|
||||
return `${wsProtocol}://${url.split("://")[1]}${COLLAB_PATH}`;
|
||||
}
|
||||
|
||||
export function getAvatarUrl(avatarUrl: string) {
|
||||
if (!avatarUrl) {
|
||||
return null;
|
||||
}
|
||||
if (!avatarUrl) return null;
|
||||
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
||||
|
||||
if (avatarUrl?.startsWith('http')) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
|
||||
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
||||
}
|
||||
|
||||
export function getSpaceUrl(spaceSlug: string) {
|
||||
return '/s/' + spaceSlug;
|
||||
return "/s/" + spaceSlug;
|
||||
}
|
||||
|
||||
export function getFileUrl(src: string) {
|
||||
return src?.startsWith('/files/') ? getBackendUrl() + src : src;
|
||||
return src?.startsWith("/files/") ? getBackendUrl() + src : src;
|
||||
}
|
||||
|
||||
export function getFileUploadSizeLimit() {
|
||||
const limit =getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
|
||||
return bytes(limit);
|
||||
const limit = getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
|
||||
return bytes(limit);
|
||||
}
|
||||
|
||||
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) {
|
||||
return window.CONFIG?.[key] || process?.env?.[key] || defaultValue;
|
||||
}
|
||||
function getConfigValue(key: string, defaultValue: string = undefined): string {
|
||||
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";
|
||||
|
||||
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 {
|
||||
if (!input) {
|
||||
export function extractPageSlugId(slug: string): string {
|
||||
if (!slug) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = input.split("-");
|
||||
return parts.length > 1 ? parts[parts.length - 1] : input;
|
||||
if (isValidUUID(slug)) {
|
||||
return slug;
|
||||
}
|
||||
const parts = slug.split("-");
|
||||
return parts.length > 1 ? parts[parts.length - 1] : slug;
|
||||
}
|
||||
|
||||
export const computeSpaceSlug = (name: string) => {
|
||||
@@ -76,3 +83,13 @@ export function decodeBase64ToSvgString(base64Data: string): string {
|
||||
export function capitalizeFirstChar(string: string) {
|
||||
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 { Button, Container, Group, Text } from "@mantine/core";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data, isLoading, isError } = useVerifyUserTokenQuery({
|
||||
token: searchParams.get("token"),
|
||||
@@ -22,11 +24,13 @@ export default function PasswordReset() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Password Reset - {getAppName()}</title>
|
||||
<title>
|
||||
{t("Password Reset")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container my={40}>
|
||||
<Text size="lg" ta="center">
|
||||
Invalid or expired password reset link
|
||||
{t("Invalid or expired password reset link")}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
@@ -35,7 +39,7 @@ export default function PasswordReset() {
|
||||
variant="subtle"
|
||||
size="md"
|
||||
>
|
||||
Goto login page
|
||||
{t("Goto login page")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
@@ -46,7 +50,9 @@ export default function PasswordReset() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Password Reset - {getAppName()}</title>
|
||||
<title>
|
||||
{t("Password Reset")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<PasswordResetForm resetToken={resetToken} />
|
||||
</>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Helmet } from "react-helmet-async";
|
||||
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useMemo } from "react";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
@@ -21,6 +20,7 @@ export default function Page() {
|
||||
data: page,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
@@ -32,7 +32,9 @@ export default function 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>;
|
||||
}
|
||||
|
||||
|
||||
+41
-41
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -28,46 +28,46 @@
|
||||
"test:e2e": "jest --config test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.701.0",
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@nestjs/bullmq": "^10.2.2",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/event-emitter": "^2.1.1",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "^2.0.6",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.4.9",
|
||||
"@nestjs/platform-socket.io": "^10.4.9",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"@nestjs/websockets": "^10.4.9",
|
||||
"@aws-sdk/client-s3": "3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.10",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.10",
|
||||
"@nestjs/event-emitter": "^3.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.0.10",
|
||||
"@nestjs/platform-socket.io": "^11.0.10",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.10",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "^1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.29.1",
|
||||
"bullmq": "^5.41.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"fix-esm": "^1.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "^15.11.6",
|
||||
"kysely": "^0.27.4",
|
||||
"kysely": "^0.27.5",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.9",
|
||||
"nestjs-kysely": "^1.0.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nanoid": "^5.1.0",
|
||||
"nestjs-kysely": "^1.1.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.13.1",
|
||||
"pg": "^8.13.3",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
@@ -75,36 +75,36 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@nestjs/cli": "^10.4.8",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.9",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@nestjs/cli": "^11.0.4",
|
||||
"@nestjs/schematics": "^11.0.1",
|
||||
"@nestjs/testing": "^11.0.10",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"globals": "^15.13.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.15.0",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.17.0",
|
||||
"prettier": "^3.4.1",
|
||||
"prettier": "^3.5.1",
|
||||
"react-email": "^3.0.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0"
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
@@ -75,6 +76,7 @@ export const tiptapExtensions = [
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention
|
||||
] as 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 { SpaceRole } from '../../common/helpers/types/permission';
|
||||
import { getPageId } from '../collaboration.util';
|
||||
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationExtension implements Extension {
|
||||
@@ -28,12 +29,15 @@ export class AuthenticationExtension implements Extension {
|
||||
const { documentName, token } = data;
|
||||
const pageId = getPageId(documentName);
|
||||
|
||||
let jwtPayload = null;
|
||||
let jwtPayload: JwtCollabPayload;
|
||||
|
||||
try {
|
||||
jwtPayload = await this.tokenService.verifyJwt(token);
|
||||
} 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;
|
||||
|
||||
@@ -12,6 +12,16 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
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()
|
||||
export class PersistenceExtension implements Extension {
|
||||
@@ -21,6 +31,7 @@ export class PersistenceExtension implements Extension {
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
@@ -85,12 +96,13 @@ export class PersistenceExtension implements Extension {
|
||||
this.logger.warn('jsonToText' + err?.['message']);
|
||||
}
|
||||
|
||||
try {
|
||||
let page = null;
|
||||
let page: Page = null;
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
page = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
@@ -99,6 +111,11 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
@@ -109,18 +126,30 @@ export class PersistenceExtension implements Extension {
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
this.eventEmitter.emit('collab.page.updated', {
|
||||
page: {
|
||||
...page,
|
||||
lastUpdatedById: context.user.id,
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
},
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
});
|
||||
} catch (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,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
|
||||
if (pathname.length > 1) {
|
||||
const value = pathname.slice(1);
|
||||
if (!isNaN(parseInt(value))){
|
||||
if (!isNaN(parseInt(value))) {
|
||||
db = parseInt(value, 10);
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,12 @@ export function createRetryStrategy() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service';
|
||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { Space } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Processor(QueueName.ATTACHEMENT_QUEUE)
|
||||
@Processor(QueueName.ATTACHMENT_QUEUE)
|
||||
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AttachmentProcessor.name);
|
||||
constructor(private readonly attachmentService: AttachmentService) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 { PasswordResetDto } from './dto/password-reset.dto';
|
||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -31,26 +34,29 @@ export class AuthController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
async login(@Req() req, @Body() loginInput: LoginDto) {
|
||||
return this.authService.login(loginInput, req.raw.workspaceId);
|
||||
async login(
|
||||
@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)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('setup')
|
||||
async setupWorkspace(
|
||||
@Req() req,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() createAdminUserDto: CreateAdminUserDto,
|
||||
) {
|
||||
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)
|
||||
@@ -76,10 +82,15 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('password-reset')
|
||||
async passwordReset(
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() passwordResetDto: PasswordResetDto,
|
||||
@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)
|
||||
@@ -90,4 +101,30 @@ export class AuthController {
|
||||
) {
|
||||
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 {
|
||||
ACCESS = 'access',
|
||||
REFRESH = 'refresh',
|
||||
COLLAB = 'collab',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -9,8 +9,8 @@ export type JwtPayload = {
|
||||
type: 'access';
|
||||
};
|
||||
|
||||
export type JwtRefreshPayload = {
|
||||
export type JwtCollabPayload = {
|
||||
sub: 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 { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { TokenService } from './token.service';
|
||||
import { TokensDto } from '../dto/tokens.dto';
|
||||
import { SignupService } from './signup.service';
|
||||
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
@@ -60,24 +59,17 @@ export class AuthService {
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
||||
|
||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
||||
return { tokens };
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
async register(createUserDto: CreateUserDto, workspaceId: string) {
|
||||
const user = await this.signupService.signup(createUserDto, workspaceId);
|
||||
|
||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
||||
|
||||
return { tokens };
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
async setup(createAdminUserDto: CreateAdminUserDto) {
|
||||
const user = await this.signupService.initialSetup(createAdminUserDto);
|
||||
|
||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
||||
|
||||
return { tokens };
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
@@ -186,7 +178,7 @@ export class AuthService {
|
||||
trx,
|
||||
);
|
||||
|
||||
trx
|
||||
await trx
|
||||
.deleteFrom('userTokens')
|
||||
.where('userId', '=', user.id)
|
||||
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
|
||||
@@ -200,9 +192,7 @@ export class AuthService {
|
||||
template: emailTemplate,
|
||||
});
|
||||
|
||||
const tokens: TokensDto = await this.tokenService.generateTokens(user);
|
||||
|
||||
return { tokens };
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
async verifyUserToken(
|
||||
@@ -222,4 +212,12 @@ export class AuthService {
|
||||
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,
|
||||
async (trx) => {
|
||||
// create user
|
||||
|
||||
const user = await this.userRepo.insertUser(
|
||||
{
|
||||
...createAdminUserDto,
|
||||
name: createAdminUserDto.name,
|
||||
email: createAdminUserDto.email,
|
||||
password: createAdminUserDto.password,
|
||||
role: UserRole.OWNER,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { TokensDto } from '../dto/tokens.dto';
|
||||
import { JwtPayload, JwtRefreshPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { JwtCollabPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
@@ -22,26 +21,19 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
|
||||
async generateRefreshToken(
|
||||
async generateCollabToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtRefreshPayload = {
|
||||
const payload: JwtCollabPayload = {
|
||||
sub: userId,
|
||||
workspaceId,
|
||||
type: JwtType.REFRESH,
|
||||
type: JwtType.COLLAB,
|
||||
};
|
||||
const expiresIn = this.environmentService.getJwtTokenExpiresIn();
|
||||
const expiresIn = '24h';
|
||||
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) {
|
||||
return this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
|
||||
@@ -23,15 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
let accessToken = null;
|
||||
|
||||
try {
|
||||
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
|
||||
} catch {
|
||||
this.logger.debug('Failed to parse access token');
|
||||
}
|
||||
|
||||
return accessToken || this.extractTokenFromHeader(req);
|
||||
return req.cookies?.authToken || this.extractTokenFromHeader(req);
|
||||
},
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: environmentService.getAppSecret(),
|
||||
|
||||
@@ -33,9 +33,21 @@ export class SearchSuggestionDTO {
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeUsers?: string;
|
||||
includeUsers?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@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();
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('suggest')
|
||||
async searchSuggestions(
|
||||
@Body() dto: SearchSuggestionDTO,
|
||||
@AuthUser() user: User,
|
||||
@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 { sql } from 'kysely';
|
||||
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
|
||||
const tsquery = require('pg-tsquery')();
|
||||
@@ -14,6 +15,7 @@ export class SearchService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private pageRepo: PageRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async searchPage(
|
||||
@@ -29,15 +31,15 @@ export class SearchService {
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'parentPageId',
|
||||
'slugId',
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
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',
|
||||
),
|
||||
])
|
||||
@@ -66,35 +68,59 @@ export class SearchService {
|
||||
|
||||
async searchSuggestions(
|
||||
suggestion: SearchSuggestionDTO,
|
||||
userId: 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 groups = [];
|
||||
let pages = [];
|
||||
|
||||
const limit = suggestion?.limit || 10;
|
||||
const query = suggestion.query.toLowerCase().trim();
|
||||
|
||||
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) {
|
||||
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 spaceMemberService: SpaceMemberService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
) {}
|
||||
|
||||
async createSpace(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { WorkspaceService } from '../services/workspace.service';
|
||||
@@ -29,6 +30,9 @@ import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../casl/interfaces/workspace-ability.type';
|
||||
import { addDays } from 'date-fns';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('workspace')
|
||||
@@ -37,6 +41,7 @@ export class WorkspaceController {
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -218,10 +223,18 @@ export class WorkspaceController {
|
||||
async acceptInvite(
|
||||
@Body() acceptInviteDto: AcceptInviteDto,
|
||||
@Req() req: any,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
return this.workspaceInvitationService.acceptInvitation(
|
||||
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
||||
acceptInviteDto,
|
||||
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';
|
||||
import { MailService } from '../../../integrations/mail/mail.service';
|
||||
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||
import { hashPassword } from '../../../common/helpers';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
@@ -24,7 +23,6 @@ import { TokenService } from '../../auth/services/token.service';
|
||||
import { nanoIdGen } from '../../../common/helpers';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { TokensDto } from '../../auth/dto/tokens.dto';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@@ -164,25 +162,22 @@ export class WorkspaceInvitationService {
|
||||
throw new BadRequestException('Invalid invitation token');
|
||||
}
|
||||
|
||||
const password = await hashPassword(dto.password);
|
||||
let newUser: User;
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
newUser = await trx
|
||||
.insertInto('users')
|
||||
.values({
|
||||
newUser = await this.userRepo.insertUser(
|
||||
{
|
||||
name: dto.name,
|
||||
email: invitation.email,
|
||||
password: password,
|
||||
workspaceId: workspaceId,
|
||||
role: invitation.role,
|
||||
lastLoginAt: new Date(),
|
||||
invitedById: invitation.invitedById,
|
||||
emailVerifiedAt: new Date(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
password: dto.password,
|
||||
role: invitation.role,
|
||||
invitedById: invitation.invitedById,
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
// add user to default group
|
||||
await this.groupUserRepo.addUserToDefaultGroup(
|
||||
@@ -254,8 +249,7 @@ export class WorkspaceInvitationService {
|
||||
});
|
||||
}
|
||||
|
||||
const tokens: TokensDto = await this.tokenService.generateTokens(newUser);
|
||||
return { tokens };
|
||||
return this.tokenService.generateAccessToken(newUser);
|
||||
}
|
||||
|
||||
async resendInvitation(
|
||||
|
||||
@@ -23,6 +23,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import * as process from 'node:process';
|
||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||
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
|
||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
@@ -68,6 +69,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
BacklinkRepo,
|
||||
],
|
||||
exports: [
|
||||
WorkspaceRepo,
|
||||
@@ -81,6 +83,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
BacklinkRepo,
|
||||
],
|
||||
})
|
||||
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) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'content',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.where('id', '=', parentPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
@@ -179,6 +188,7 @@ export class PageRepo {
|
||||
'p.content',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
])
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||
),
|
||||
|
||||
@@ -97,7 +97,7 @@ export class SpaceMemberRepo {
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const query = this.db
|
||||
let query = this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
||||
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
||||
@@ -116,6 +116,16 @@ export class SpaceMemberRepo {
|
||||
.where('spaceId', '=', spaceId)
|
||||
.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, {
|
||||
page: pagination.page,
|
||||
perPage: pagination.limit,
|
||||
|
||||
@@ -99,7 +99,8 @@ export class UserRepo {
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<User> {
|
||||
const user: InsertableUser = {
|
||||
name: insertableUser.name || insertableUser.email.toLowerCase(),
|
||||
name:
|
||||
insertableUser.name || insertableUser.email.split('@')[0].toLowerCase(),
|
||||
email: insertableUser.email.toLowerCase(),
|
||||
password: await hashPassword(insertableUser.password),
|
||||
locale: 'en-US',
|
||||
@@ -110,7 +111,7 @@ export class UserRepo {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('users')
|
||||
.values(user)
|
||||
.values({ ...insertableUser, ...user })
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
+16
-3
@@ -42,6 +42,15 @@ export interface Attachments {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Backlinks {
|
||||
createdAt: Generated<Timestamp>;
|
||||
id: Generated<string>;
|
||||
sourcePageId: string;
|
||||
targetPageId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Comments {
|
||||
content: Json | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
@@ -51,6 +60,7 @@ export interface Comments {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
parentCommentId: string | null;
|
||||
resolvedAt: Timestamp | null;
|
||||
selection: string | null;
|
||||
type: string | null;
|
||||
workspaceId: string;
|
||||
@@ -59,6 +69,7 @@ export interface Comments {
|
||||
export interface Groups {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
description: string | null;
|
||||
id: Generated<string>;
|
||||
isDefault: boolean;
|
||||
@@ -118,6 +129,7 @@ export interface Pages {
|
||||
export interface SpaceMembers {
|
||||
addedById: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
groupId: string | null;
|
||||
id: Generated<string>;
|
||||
role: string;
|
||||
@@ -135,7 +147,7 @@ export interface Spaces {
|
||||
id: Generated<string>;
|
||||
logo: string | null;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
slug: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
visibility: Generated<string>;
|
||||
workspaceId: string;
|
||||
@@ -155,7 +167,7 @@ export interface Users {
|
||||
locale: string | null;
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
role: string;
|
||||
role: string | null;
|
||||
settings: Json | null;
|
||||
timezone: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
@@ -186,13 +198,13 @@ export interface WorkspaceInvitations {
|
||||
}
|
||||
|
||||
export interface Workspaces {
|
||||
allowedEmailDomains: Generated<string[] | null>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
customDomain: string | null;
|
||||
defaultRole: Generated<string>;
|
||||
defaultSpaceId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
description: string | null;
|
||||
emailDomains: Generated<string[] | null>;
|
||||
hostname: string | null;
|
||||
id: Generated<string>;
|
||||
logo: string | null;
|
||||
@@ -203,6 +215,7 @@ export interface Workspaces {
|
||||
|
||||
export interface DB {
|
||||
attachments: Attachments;
|
||||
backlinks: Backlinks;
|
||||
comments: Comments;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SpaceMembers,
|
||||
WorkspaceInvitations,
|
||||
UserTokens,
|
||||
Backlinks,
|
||||
} from './db';
|
||||
|
||||
// Workspace
|
||||
@@ -76,4 +77,9 @@ export type UpdatableAttachment = Updateable<Omit<Attachments, 'id'>>;
|
||||
// User Token
|
||||
export type UserToken = Selectable<UserTokens>;
|
||||
export type InsertableUserToken = Insertable<UserTokens>;
|
||||
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
|
||||
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
|
||||
|
||||
// Backlink
|
||||
export type Backlink = Selectable<Backlinks>;
|
||||
export type InsertableBacklink = Insertable<Backlink>;
|
||||
export type UpdatableBacklink = Updateable<Omit<Backlink, 'id'>>;
|
||||
|
||||
@@ -16,6 +16,16 @@ export class EnvironmentService {
|
||||
);
|
||||
}
|
||||
|
||||
isHttps(): boolean {
|
||||
const appUrl = this.configService.get<string>('APP_URL');
|
||||
try {
|
||||
const url = new URL(appUrl);
|
||||
return url.protocol === 'https:';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return parseInt(this.configService.get<string>('PORT', '3000'));
|
||||
}
|
||||
@@ -44,7 +54,6 @@ export class EnvironmentService {
|
||||
}
|
||||
|
||||
getFileUploadSizeLimit(): string {
|
||||
|
||||
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsNotIn,
|
||||
IsOptional,
|
||||
IsUrl,
|
||||
MinLength,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
@@ -36,6 +37,7 @@ export class EnvironmentVariables {
|
||||
APP_URL: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(32)
|
||||
@IsNotIn(['REPLACE_WITH_LONG_SECRET'])
|
||||
APP_SECRET: string;
|
||||
|
||||
|
||||
@@ -76,7 +76,11 @@ export class ExportController {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||
const rawContent = await this.exportService.exportPage(
|
||||
dto.format,
|
||||
page,
|
||||
true,
|
||||
);
|
||||
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(fileExt),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
||||
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
||||
import { turndown } from './turndown-utils';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
updateAttachmentUrls,
|
||||
} from './utils';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { EditorState } from '@tiptap/pm/state';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import slugify = require('@sindresorhus/slugify');
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
@@ -33,16 +38,27 @@ export class ExportService {
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async exportPage(format: string, page: Page) {
|
||||
async exportPage(format: string, page: Page, singlePage?: boolean) {
|
||||
const titleNode = {
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: getPageTitle(page.title) }],
|
||||
};
|
||||
|
||||
const prosemirrorJson: any = getProsemirrorContent(page.content);
|
||||
let prosemirrorJson: any;
|
||||
|
||||
if (singlePage) {
|
||||
prosemirrorJson = await this.turnPageMentionsToLinks(
|
||||
getProsemirrorContent(page.content),
|
||||
page.workspaceId,
|
||||
);
|
||||
} else {
|
||||
// mentions is already turned to links during the zip process
|
||||
prosemirrorJson = getProsemirrorContent(page.content);
|
||||
}
|
||||
|
||||
if (page.title) {
|
||||
prosemirrorJson.content.unshift(titleNode);
|
||||
@@ -115,7 +131,8 @@ export class ExportService {
|
||||
'pages.title',
|
||||
'pages.content',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId'
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
@@ -160,7 +177,10 @@ export class ExportService {
|
||||
for (const page of children) {
|
||||
const childPages = tree[page.id] || [];
|
||||
|
||||
const prosemirrorJson = getProsemirrorContent(page.content);
|
||||
const prosemirrorJson = await this.turnPageMentionsToLinks(
|
||||
getProsemirrorContent(page.content),
|
||||
page.workspaceId,
|
||||
);
|
||||
|
||||
const currentPagePath = slugIdToPath[page.slugId];
|
||||
|
||||
@@ -219,4 +239,107 @@ export class ExportService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
const pageMentionIds = [];
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
||||
if (node.attrs.entityId) {
|
||||
pageMentionIds.push(node.attrs.entityId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (pageMentionIds.length < 1) {
|
||||
return prosemirrorJson;
|
||||
}
|
||||
|
||||
const pages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withSpace(eb))
|
||||
.where('id', 'in', pageMentionIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
const pageMap = new Map(pages.map((page) => [page.id, page]));
|
||||
|
||||
let editorState = EditorState.create({
|
||||
doc: doc,
|
||||
});
|
||||
|
||||
const transaction = editorState.tr;
|
||||
|
||||
let offset = 0;
|
||||
|
||||
/**
|
||||
* Helper function to replace a mention node with a link node.
|
||||
*/
|
||||
const replaceMentionWithLink = (
|
||||
node: Node,
|
||||
pos: number,
|
||||
title: string,
|
||||
slugId: string,
|
||||
spaceSlug: string,
|
||||
) => {
|
||||
const linkTitle = title || 'untitled';
|
||||
const truncatedTitle = linkTitle?.substring(0, 70);
|
||||
const pageSlug = `${slugify(truncatedTitle)}-${slugId}`;
|
||||
|
||||
// Create the link URL
|
||||
const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`;
|
||||
|
||||
// Create a link mark and a text node with that mark
|
||||
const linkMark = editorState.schema.marks.link.create({ href: link });
|
||||
const linkTextNode = editorState.schema.text(linkTitle, [linkMark]);
|
||||
|
||||
// Calculate positions (adjusted by the current offset)
|
||||
const from = pos + offset;
|
||||
const to = pos + offset + node.nodeSize;
|
||||
|
||||
// Replace the node in the transaction and update the offset
|
||||
transaction.replaceWith(from, to, linkTextNode);
|
||||
offset += linkTextNode.nodeSize - node.nodeSize;
|
||||
};
|
||||
|
||||
// find and convert page mentions to links
|
||||
editorState.doc.descendants((node: Node, pos: number) => {
|
||||
// Check if the node is a page mention
|
||||
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
||||
const { entityId: pageId, slugId, label } = node.attrs;
|
||||
const page = pageMap.get(pageId);
|
||||
|
||||
if (page) {
|
||||
replaceMentionWithLink(
|
||||
node,
|
||||
pos,
|
||||
page.title,
|
||||
page.slugId,
|
||||
page.space.slug,
|
||||
);
|
||||
} else {
|
||||
// if page is not found, default to the node label and slugId
|
||||
replaceMentionWithLink(node, pos, label, slugId, 'undefined');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (transaction.docChanged) {
|
||||
editorState = editorState.apply(transaction);
|
||||
}
|
||||
|
||||
const updatedDoc = editorState.doc;
|
||||
|
||||
return updatedDoc.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { Page } from '@docmost/db/types/entity.types';
|
||||
|
||||
export type PageExportTree = Record<string, Page[]>;
|
||||
|
||||
export const INTERNAL_LINK_REGEX =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
|
||||
export function getExportExtension(format: string) {
|
||||
if (format === ExportFormat.HTML) {
|
||||
return '.html';
|
||||
@@ -83,13 +86,11 @@ export function replaceInternalLinks(
|
||||
currentPagePath: string,
|
||||
) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
const internalLinkRegex =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
for (const mark of node.marks) {
|
||||
if (mark.type.name === 'link' && mark.attrs.href) {
|
||||
const match = mark.attrs.href.match(internalLinkRegex);
|
||||
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
|
||||
if (match) {
|
||||
const markLink = mark.attrs.href;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user