diff --git a/package.json b/package.json index a7d634f3c..84ed59eaa 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,13 @@ "fortawesome": "^0.0.1-security", "google-auth-library": "^9.14.1", "googleapis": "^144.0.0", + + "i18next": "^24.0.2", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-http-backend": "^3.0.1", + "idcac-playwright": "^0.1.3", + "ioredis": "^5.4.1", "joi": "^17.6.0", "jsonwebtoken": "^9.0.2", @@ -57,6 +63,7 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "react-highlight": "0.15.0", + "react-i18next": "^15.1.3", "react-router-dom": "^6.26.1", "react-simple-code-editor": "^0.11.2", "react-transition-group": "^4.4.2", diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 000000000..1ade8e2a6 --- /dev/null +++ b/public/locales/de.json @@ -0,0 +1,224 @@ +{ + "login": { + "title": "Willkommen zurück!", + "email": "E-Mail", + "password": "Passwort", + "button": "Einloggen", + "loading": "Lädt", + "register_prompt": "Noch keinen Account?", + "register_link": "Registrieren", + "welcome_notification": "Willkommen bei Maxun!", + "error_notification": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." + }, + "register": { + "title": "Konto registrieren", + "email": "E-Mail", + "password": "Passwort", + "button": "Registrieren", + "loading": "Lädt", + "register_prompt": "Bereits ein Konto?", + "login_link": "Einloggen", + "welcome_notification": "Willkommen bei Maxun!", + "error_notification": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut." + }, + "recordingtable": { + "run": "Ausführen", + "name": "Name", + "schedule": "Zeitplan", + "integrate": "Integrieren", + "settings": "Einstellungen", + "options": "Optionen", + "heading": "Meine Roboter", + "new": "Roboter erstellen", + "modal": { + "title": "Geben Sie die URL ein", + "label": "URL", + "button": "Aufnahme starten" + }, + "edit": "Bearbeiten", + "delete": "Löschen", + "duplicate": "Duplizieren" + }, + "mainmenu": { + "recordings": "Roboter", + "runs": "Ausführungen", + "proxy": "Proxy", + "apikey": "API-Schlüssel", + "feedback": "Maxun Cloud beitreten", + "apidocs": "API-Dokumentation" + }, + "runstable": { + "runs": "Alle Ausführungen", + "runStatus": "Status", + "runName": "Name", + "startedAt": "Gestartet am", + "finishedAt": "Beendet am", + "delete": "Löschen", + "settings": "Einstellungen", + "search": "Ausführungen suchen..." + }, + "proxy": { + "title": "Proxy-Konfiguration", + "tab_standard": "Standard-Proxy", + "tab_rotation": "Automatische Proxy-Rotation", + "server_url": "Proxy-Server-URL", + "server_url_helper": "Proxy für alle Roboter. HTTP- und SOCKS-Proxys werden unterstützt. Beispiel http://myproxy.com:3128 oder socks5://myproxy.com:3128. Kurzform myproxy.com:3128 wird als HTTP-Proxy behandelt.", + "requires_auth": "Authentifizierung erforderlich?", + "username": "Benutzername", + "password": "Passwort", + "add_proxy": "Proxy hinzufügen", + "test_proxy": "Proxy testen", + "remove_proxy": "Proxy entfernen", + "table": { + "proxy_url": "Proxy-URL", + "requires_auth": "Authentifizierung erforderlich" + }, + "coming_soon": "Demnächst verfügbar - In Open Source (Basis-Rotation) & Cloud (Erweiterte Rotation). Wenn Sie die Infrastruktur nicht selbst verwalten möchten, tragen Sie sich in unsere Cloud-Warteliste ein.", + "join_waitlist": "Maxun Cloud Warteliste beitreten", + "alert": { + "title": "Wenn Ihr Proxy einen Benutzernamen und ein Passwort erfordert, geben Sie diese immer separat von der Proxy-URL an.", + "right_way": "Der richtige Weg", + "wrong_way": "Der falsche Weg", + "proxy_url": "Proxy-URL:", + "username": "Benutzername:", + "password": "Passwort:" + }, + "notifications": { + "config_success": "Proxy-Konfiguration erfolgreich übermittelt", + "config_error": "Fehler beim Übermitteln der Proxy-Konfiguration. Bitte erneut versuchen.", + "test_success": "Proxy-Konfiguration funktioniert", + "test_error": "Fehler beim Testen der Proxy-Konfiguration. Bitte erneut versuchen.", + "fetch_success": "Proxy-Konfiguration erfolgreich abgerufen", + "remove_success": "Proxy-Konfiguration erfolgreich entfernt", + "remove_error": "Fehler beim Entfernen der Proxy-Konfiguration. Bitte erneut versuchen." + } + }, + "apikey": { + "title": "API-Schlüssel verwalten", + "default_name": "Maxun API-Schlüssel", + "table": { + "name": "API-Schlüssel Name", + "key": "API-Schlüssel", + "actions": "Aktionen" + }, + "actions": { + "copy": "Kopieren", + "show": "Anzeigen", + "hide": "Ausblenden", + "delete": "Löschen" + }, + "no_key_message": "Sie haben noch keinen API-Schlüssel generiert.", + "generate_button": "API-Schlüssel generieren", + "notifications": { + "fetch_error": "API-Schlüssel konnte nicht abgerufen werden - ${error}", + "generate_success": "API-Schlüssel erfolgreich generiert", + "generate_error": "API-Schlüssel konnte nicht generiert werden - ${error}", + "delete_success": "API-Schlüssel erfolgreich gelöscht", + "delete_error": "API-Schlüssel konnte nicht gelöscht werden - ${error}", + "copy_success": "API-Schlüssel erfolgreich kopiert" + } + }, + "action_description": { + "text": { + "title": "Text erfassen", + "description": "Fahren Sie über die Texte, die Sie extrahieren möchten, und klicken Sie, um sie auszuwählen" + }, + "screenshot": { + "title": "Screenshot erfassen", + "description": "Erfassen Sie einen Teil- oder Vollbildschirmfoto der aktuellen Seite." + }, + "list": { + "title": "Liste erfassen", + "description": "Fahren Sie über die Liste, die Sie extrahieren möchten. Nach der Auswahl können Sie über alle Texte in der ausgewählten Liste fahren. Klicken Sie zum Auswählen." + }, + "default": { + "title": "Welche Daten möchten Sie extrahieren?", + "description": "Ein Roboter ist darauf ausgelegt, eine Aktion nach der anderen auszuführen. Sie können eine der folgenden Optionen wählen." + }, + "list_stages": { + "initial": "Wählen Sie die Liste aus, die Sie extrahieren möchten, zusammen mit den darin enthaltenen Texten", + "pagination": "Wählen Sie aus, wie der Roboter den Rest der Liste erfassen kann", + "limit": "Wählen Sie die Anzahl der zu extrahierenden Elemente", + "complete": "Erfassung ist abgeschlossen" + } + }, + "right_panel": { + "buttons": { + "capture_list": "Liste erfassen", + "capture_text": "Text erfassen", + "capture_screenshot": "Screenshot erfassen", + "confirm": "Bestätigen", + "discard": "Verwerfen", + "confirm_capture": "Erfassung bestätigen", + "confirm_pagination": "Paginierung bestätigen", + "confirm_limit": "Limit bestätigen", + "finish_capture": "Erfassung abschließen", + "finish": "Fertig", + "cancel": "Abbrechen" + }, + "screenshot": { + "capture_fullpage": "Vollständige Seite erfassen", + "capture_visible": "Sichtbaren Bereich erfassen", + "display_fullpage": "Vollständige Seite Screenshot", + "display_visible": "Sichtbarer Bereich Screenshot" + }, + "pagination": { + "title": "Wie können wir das nächste Listenelement auf der Seite finden?", + "click_next": "Auf 'Weiter' klicken, um zur nächsten Seite zu navigieren", + "click_load_more": "Auf 'Mehr laden' klicken, um weitere Elemente zu laden", + "scroll_down": "Nach unten scrollen, um mehr Elemente zu laden", + "scroll_up": "Nach oben scrollen, um mehr Elemente zu laden", + "none": "Keine weiteren Elemente zu laden" + }, + "limit": { + "title": "Wie viele Zeilen möchten Sie maximal extrahieren?", + "custom": "Benutzerdefiniert", + "enter_number": "Nummer eingeben" + }, + "fields": { + "label": "Bezeichnung", + "data": "Daten", + "field_label": "Feldbezeichnung", + "field_data": "Felddaten" + }, + "messages": { + "list_selected": "Liste erfolgreich ausgewählt" + }, + "errors": { + "select_pagination": "Bitte wählen Sie einen Paginierungstyp aus.", + "select_pagination_element": "Bitte wählen Sie zuerst das Paginierungselement aus.", + "select_limit": "Bitte wählen Sie ein Limit oder geben Sie ein benutzerdefiniertes Limit ein.", + "invalid_limit": "Bitte geben Sie ein gültiges Limit ein.", + "confirm_text_fields": "Bitte bestätigen Sie alle Textfelder", + "unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.", + "capture_text_discarded": "Texterfassung verworfen", + "capture_list_discarded": "Listenerfassung verworfen" + } + }, + "save_recording": { + "title": "Roboter speichern", + "robot_name": "Roboter Name", + "buttons": { + "save": "Speichern", + "confirm": "Bestätigen" + }, + "notifications": { + "save_success": "Roboter erfolgreich gespeichert" + }, + "errors": { + "user_not_logged": "Benutzer nicht angemeldet. Aufnahme kann nicht gespeichert werden.", + "exists_warning": "Ein Roboter mit diesem Namen existiert bereits, bitte bestätigen Sie das Überschreiben des Roboters." + }, + "tooltips": { + "saving": "Workflow wird optimiert und gespeichert" + } + }, + "browser_recording": { + "modal": { + "confirm_discard": "Sind Sie sicher, dass Sie die Aufnahme verwerfen möchten?" + }, + "notifications": { + "terminated": "Aktuelle Aufnahme wurde beendet" + } + } +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 000000000..4b8560086 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,227 @@ +{ + "login": { + "title": "Welcome Back!", + "email": "Email", + "password": "Password", + "button": "Login", + "loading": "Loading", + "register_prompt": "Don't have an account?", + "register_link": "Register", + "welcome_notification": "Welcome to Maxun!", + "error_notification": "Login Failed. Please try again." + }, + "register": { + "title": "Register Account", + "email": "Email", + "password": "Password", + "button": "Register", + "loading": "Loading", + "register_prompt": "Already have an account?", + "login_link": "Login", + "welcome_notification": "Welcome to Maxun!", + "error_notification": "Registeration Failed. Please try again." + }, + "recordingtable":{ + "run": "Run", + "name": "Name", + "schedule": "Schedule", + "integrate": "Integrate", + "settings": "Settings", + "options": "Options", + "heading":"My Robots", + "new":"Create Robot", + "modal":{ + "title":"Enter the URL", + "label":"URL", + "button":"Start Recording" + }, + "edit":"Edit", + "delete":"Delete", + "duplicate":"Duplicate", + "search":"Search Robots..." + + }, + "mainmenu":{ + "recordings": "Robots", + "runs": "Runs", + "proxy": "Proxy", + "apikey": "API Key", + "feedback":"Join Maxun Cloud", + "apidocs":"API Docs" + + }, + "runstable":{ + "runs":"All Runs", + "runStatus":"Status", + "runName":"Name", + "startedAt":"Started At", + "finishedAt":"Finished At", + "delete":"Delete", + "settings":"Settings", + "search":"Search Runs..." + }, + "proxy": { + "title": "Proxy Configuration", + "tab_standard": "Standard Proxy", + "tab_rotation": "Automatic Proxy Rotation", + "server_url": "Proxy Server URL", + "server_url_helper": "Proxy to be used for all robots. HTTP and SOCKS proxies are supported. Example http://myproxy.com:3128 or socks5://myproxy.com:3128. Short form myproxy.com:3128 is considered an HTTP proxy.", + "requires_auth": "Requires Authentication?", + "username": "Username", + "password": "Password", + "add_proxy": "Add Proxy", + "test_proxy": "Test Proxy", + "remove_proxy": "Remove Proxy", + "table": { + "proxy_url": "Proxy URL", + "requires_auth": "Requires Authentication" + }, + "coming_soon": "Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access.", + "join_waitlist": "Join Maxun Cloud Waitlist", + "alert": { + "title": "If your proxy requires a username and password, always provide them separately from the proxy URL.", + "right_way": "The right way", + "wrong_way": "The wrong way", + "proxy_url": "Proxy URL:", + "username": "Username:", + "password": "Password:" + }, + "notifications": { + "config_success": "Proxy configuration submitted successfully", + "config_error": "Failed to submit proxy configuration. Try again.", + "test_success": "Proxy configuration is working", + "test_error": "Failed to test proxy configuration. Try again.", + "fetch_success": "Proxy configuration fetched successfully", + "remove_success": "Proxy configuration removed successfully", + "remove_error": "Failed to remove proxy configuration. Try again." + } + }, + "apikey": { + "title": "Manage Your API Key", + "default_name": "Maxun API Key", + "table": { + "name": "API Key Name", + "key": "API Key", + "actions": "Actions" + }, + "actions": { + "copy": "Copy", + "show": "Show", + "hide": "Hide", + "delete": "Delete" + }, + "no_key_message": "You haven't generated an API key yet.", + "generate_button": "Generate API Key", + "notifications": { + "fetch_error": "Failed to fetch API Key - ${error}", + "generate_success": "Generated API Key successfully", + "generate_error": "Failed to generate API Key - ${error}", + "delete_success": "API Key deleted successfully", + "delete_error": "Failed to delete API Key - ${error}", + "copy_success": "Copied API Key successfully" + } + }, + "action_description": { + "text": { + "title": "Capture Text", + "description": "Hover over the texts you want to extract and click to select them" + }, + "screenshot": { + "title": "Capture Screenshot", + "description": "Capture a partial or full page screenshot of the current page." + }, + "list": { + "title": "Capture List", + "description": "Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them." + }, + "default": { + "title": "What data do you want to extract?", + "description": "A robot is designed to perform one action at a time. You can choose any of the options below." + }, + "list_stages": { + "initial": "Select the list you want to extract along with the texts inside it", + "pagination": "Select how the robot can capture the rest of the list", + "limit": "Choose the number of items to extract", + "complete": "Capture is complete" + } + }, + "right_panel": { + "buttons": { + "capture_list": "Capture List", + "capture_text": "Capture Text", + "capture_screenshot": "Capture Screenshot", + "confirm": "Confirm", + "discard": "Discard", + "confirm_capture": "Confirm Capture", + "confirm_pagination": "Confirm Pagination", + "confirm_limit": "Confirm Limit", + "finish_capture": "Finish Capture", + "finish": "Finish", + "cancel": "Cancel" + }, + "screenshot": { + "capture_fullpage": "Capture Fullpage", + "capture_visible": "Capture Visible Part", + "display_fullpage": "Take Fullpage Screenshot", + "display_visible": "Take Visible Part Screenshot" + }, + "pagination": { + "title": "How can we find the next list item on the page?", + "click_next": "Click on next to navigate to the next page", + "click_load_more": "Click on load more to load more items", + "scroll_down": "Scroll down to load more items", + "scroll_up": "Scroll up to load more items", + "none": "No more items to load" + }, + "limit": { + "title": "What is the maximum number of rows you want to extract?", + "custom": "Custom", + "enter_number": "Enter number" + }, + "fields": { + "label": "Label", + "data": "Data", + "field_label": "Field Label", + "field_data": "Field Data" + }, + "messages": { + "list_selected": "List Selected Successfully" + }, + "errors": { + "select_pagination": "Please select a pagination type.", + "select_pagination_element": "Please select the pagination element first.", + "select_limit": "Please select a limit or enter a custom limit.", + "invalid_limit": "Please enter a valid limit.", + "confirm_text_fields": "Please confirm all text fields", + "unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.", + "capture_text_discarded": "Capture Text Discarded", + "capture_list_discarded": "Capture List Discarded" + } + }, + "save_recording": { + "title": "Save Robot", + "robot_name": "Robot Name", + "buttons": { + "save": "Save", + "confirm": "Confirm" + }, + "notifications": { + "save_success": "Robot saved successfully" + }, + "errors": { + "user_not_logged": "User not logged in. Cannot save recording.", + "exists_warning": "Robot with this name already exists, please confirm the Robot's overwrite." + }, + "tooltips": { + "saving": "Optimizing and saving the workflow" + } + }, + "browser_recording": { + "modal": { + "confirm_discard": "Are you sure you want to discard the recording?" + }, + "notifications": { + "terminated": "Current Recording was terminated" + } + } + } \ No newline at end of file diff --git a/public/locales/es.json b/public/locales/es.json new file mode 100644 index 000000000..fb68ef015 --- /dev/null +++ b/public/locales/es.json @@ -0,0 +1,225 @@ +{ + "login": { + "title": "¡Bienvenido de nuevo!", + "email": "Correo electrónico", + "password": "Contraseña", + "button": "Iniciar sesión", + "loading": "Cargando", + "register_prompt": "¿No tienes una cuenta?", + "register_link": "Registrarse", + "welcome_notification": "¡Bienvenido a Maxun!", + "error_notification": "Error al iniciar sesión. Por favor, inténtalo de nuevo." + }, + "register": { + "title": "Crear cuenta", + "email": "Correo electrónico", + "password": "Contraseña", + "button": "Registrarse", + "loading": "Cargando", + "register_prompt": "¿Ya tienes una cuenta?", + "login_link": "Iniciar sesión", + "welcome_notification": "¡Bienvenido a Maxun!", + "error_notification": "Error en el registro. Por favor, inténtalo de nuevo." + }, + "recordingtable": { + "run": "Ejecutar", + "name": "Nombre", + "schedule": "Programar", + "integrate": "Integrar", + "settings": "Ajustes", + "options": "Opciones", + "heading": "Mis Robots", + "new": "Crear Robot", + "modal": { + "title": "Ingresa la URL", + "label": "URL", + "button": "Comenzar grabación" + }, + "edit": "Editar", + "delete": "Eliminar", + "duplicate": "Duplicar", + "search": "Buscar robots..." + }, + "mainmenu": { + "recordings": "Robots", + "runs": "Ejecuciones", + "proxy": "Proxy", + "apikey": "Clave API", + "feedback": "Unirse a Maxun Cloud", + "apidocs": "Documentación API" + }, + "runstable": { + "runs": "Todas las ejecuciones", + "runStatus": "Estado", + "runName": "Nombre", + "startedAt": "Iniciado el", + "finishedAt": "Finalizado el", + "delete": "Eliminar", + "settings": "Ajustes", + "search": "Buscar ejecuciones..." + }, + "proxy": { + "title": "Configuración del Proxy", + "tab_standard": "Proxy Estándar", + "tab_rotation": "Rotación Automática de Proxy", + "server_url": "URL del Servidor Proxy", + "server_url_helper": "Proxy para usar en todos los robots. Se admiten proxies HTTP y SOCKS. Ejemplo http://myproxy.com:3128 o socks5://myproxy.com:3128. La forma corta myproxy.com:3128 se considera un proxy HTTP.", + "requires_auth": "¿Requiere Autenticación?", + "username": "Usuario", + "password": "Contraseña", + "add_proxy": "Agregar Proxy", + "test_proxy": "Probar Proxy", + "remove_proxy": "Eliminar Proxy", + "table": { + "proxy_url": "URL del Proxy", + "requires_auth": "Requiere Autenticación" + }, + "coming_soon": "Próximamente - En Open Source (Rotación Básica) y Cloud (Rotación Avanzada). Si no desea administrar la infraestructura, únase a nuestra lista de espera en la nube para obtener acceso anticipado.", + "join_waitlist": "Unirse a la Lista de Espera de Maxun Cloud", + "alert": { + "title": "Si su proxy requiere un nombre de usuario y contraseña, proporcione siempre estos datos por separado de la URL del proxy.", + "right_way": "La forma correcta", + "wrong_way": "La forma incorrecta", + "proxy_url": "URL del Proxy:", + "username": "Usuario:", + "password": "Contraseña:" + }, + "notifications": { + "config_success": "Configuración del proxy enviada con éxito", + "config_error": "Error al enviar la configuración del proxy. Inténtelo de nuevo.", + "test_success": "La configuración del proxy funciona correctamente", + "test_error": "Error al probar la configuración del proxy. Inténtelo de nuevo.", + "fetch_success": "Configuración del proxy recuperada con éxito", + "remove_success": "Configuración del proxy eliminada con éxito", + "remove_error": "Error al eliminar la configuración del proxy. Inténtelo de nuevo." + } + }, + "apikey": { + "title": "Gestionar tu Clave API", + "default_name": "Clave API de Maxun", + "table": { + "name": "Nombre de la Clave API", + "key": "Clave API", + "actions": "Acciones" + }, + "actions": { + "copy": "Copiar", + "show": "Mostrar", + "hide": "Ocultar", + "delete": "Eliminar" + }, + "no_key_message": "Aún no has generado una clave API.", + "generate_button": "Generar Clave API", + "notifications": { + "fetch_error": "Error al obtener la clave API - ${error}", + "generate_success": "Clave API generada con éxito", + "generate_error": "Error al generar la clave API - ${error}", + "delete_success": "Clave API eliminada con éxito", + "delete_error": "Error al eliminar la clave API - ${error}", + "copy_success": "Clave API copiada con éxito" + } + }, + "action_description": { + "text": { + "title": "Capturar Texto", + "description": "Pase el cursor sobre los textos que desea extraer y haga clic para seleccionarlos" + }, + "screenshot": { + "title": "Capturar Pantalla", + "description": "Capture una captura de pantalla parcial o completa de la página actual." + }, + "list": { + "title": "Capturar Lista", + "description": "Pase el cursor sobre la lista que desea extraer. Una vez seleccionada, puede pasar el cursor sobre todos los textos dentro de la lista seleccionada. Haga clic para seleccionarlos." + }, + "default": { + "title": "¿Qué datos desea extraer?", + "description": "Un robot está diseñado para realizar una acción a la vez. Puede elegir cualquiera de las siguientes opciones." + }, + "list_stages": { + "initial": "Seleccione la lista que desea extraer junto con los textos que contiene", + "pagination": "Seleccione cómo puede el robot capturar el resto de la lista", + "limit": "Elija el número de elementos a extraer", + "complete": "Captura completada" + } + }, + "right_panel": { + "buttons": { + "capture_list": "Capturar Lista", + "capture_text": "Capturar Texto", + "capture_screenshot": "Capturar Pantalla", + "confirm": "Confirmar", + "discard": "Descartar", + "confirm_capture": "Confirmar Captura", + "confirm_pagination": "Confirmar Paginación", + "confirm_limit": "Confirmar Límite", + "finish_capture": "Finalizar Captura", + "finish": "Finalizar", + "cancel": "Cancelar" + }, + "screenshot": { + "capture_fullpage": "Capturar Página Completa", + "capture_visible": "Capturar Parte Visible", + "display_fullpage": "Capturar Screenshot de Página Completa", + "display_visible": "Capturar Screenshot de Parte Visible" + }, + "pagination": { + "title": "¿Cómo podemos encontrar el siguiente elemento de la lista en la página?", + "click_next": "Hacer clic en siguiente para navegar a la siguiente página", + "click_load_more": "Hacer clic en cargar más para cargar más elementos", + "scroll_down": "Desplazarse hacia abajo para cargar más elementos", + "scroll_up": "Desplazarse hacia arriba para cargar más elementos", + "none": "No hay más elementos para cargar" + }, + "limit": { + "title": "¿Cuál es el número máximo de filas que desea extraer?", + "custom": "Personalizado", + "enter_number": "Ingrese número" + }, + "fields": { + "label": "Etiqueta", + "data": "Datos", + "field_label": "Etiqueta del Campo", + "field_data": "Datos del Campo" + }, + "messages": { + "list_selected": "Lista seleccionada exitosamente" + }, + "errors": { + "select_pagination": "Por favor seleccione un tipo de paginación.", + "select_pagination_element": "Por favor seleccione primero el elemento de paginación.", + "select_limit": "Por favor seleccione un límite o ingrese un límite personalizado.", + "invalid_limit": "Por favor ingrese un límite válido.", + "confirm_text_fields": "Por favor confirme todos los campos de texto", + "unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.", + "capture_text_discarded": "Captura de texto descartada", + "capture_list_discarded": "Captura de lista descartada" + } + }, + "save_recording": { + "title": "Guardar Robot", + "robot_name": "Nombre del Robot", + "buttons": { + "save": "Guardar", + "confirm": "Confirmar" + }, + "notifications": { + "save_success": "Robot guardado exitosamente" + }, + "errors": { + "user_not_logged": "Usuario no conectado. No se puede guardar la grabación.", + "exists_warning": "Ya existe un robot con este nombre, por favor confirme la sobrescritura del robot." + }, + "tooltips": { + "saving": "Optimizando y guardando el flujo de trabajo" + } + }, + "browser_recording": { + "modal": { + "confirm_discard": "¿Está seguro de que desea descartar la grabación?" + }, + "notifications": { + "terminated": "La grabación actual fue terminada" + } + } +} \ No newline at end of file diff --git a/public/locales/ja.json b/public/locales/ja.json new file mode 100644 index 000000000..0202160fd --- /dev/null +++ b/public/locales/ja.json @@ -0,0 +1,225 @@ +{ + "login": { + "title": "お帰りなさい!", + "email": "メールアドレス", + "password": "パスワード", + "button": "ログイン", + "loading": "読み込み中", + "register_prompt": "アカウントをお持ちでないですか?", + "register_link": "登録する", + "welcome_notification": "Maxunへようこそ!", + "error_notification": "ログインに失敗しました。もう一度お試しください。" + }, + "register": { + "title": "アカウントを登録する", + "email": "メールアドレス", + "password": "パスワード", + "button": "登録する", + "loading": "読み込み中", + "register_prompt": "既にアカウントをお持ちですか?", + "login_link": "ログイン", + "welcome_notification": "Maxunへようこそ!", + "error_notification": "登録に失敗しました。もう一度お試しください。" + }, + "recordingtable": { + "run": "実行", + "name": "名前", + "schedule": "スケジュール", + "integrate": "統合", + "settings": "設定", + "options": "オプション", + "heading": "私のロボット", + "new": "ロボットを作成", + "modal": { + "title": "URLを入力してください", + "label": "URL", + "button": "録画を開始" + }, + "edit": "編集", + "delete": "削除", + "duplicate": "複製", + "search": "ロボットを検索..." + }, + "mainmenu": { + "recordings": "ロボット", + "runs": "実行", + "proxy": "プロキシ", + "apikey": "APIキー", + "feedback": "Maxunクラウドに参加する", + "apidocs": "APIドキュメント" + }, + "runstable": { + "runs": "すべての実行", + "runStatus": "ステータス", + "runName": "名前", + "startedAt": "開始日時", + "finishedAt": "終了日時", + "delete": "削除", + "settings": "設定", + "search": "実行を検索..." + }, + "proxy": { + "title": "プロキシ設定", + "tab_standard": "標準プロキシ", + "tab_rotation": "自動プロキシローテーション", + "server_url": "プロキシサーバーURL", + "server_url_helper": "すべてのロボットで使用するプロキシ。HTTPとSOCKSプロキシがサポートされています。例:http://myproxy.com:3128 または socks5://myproxy.com:3128。短縮形 myproxy.com:3128 はHTTPプロキシとして扱われます。", + "requires_auth": "認証が必要ですか?", + "username": "ユーザー名", + "password": "パスワード", + "add_proxy": "プロキシを追加", + "test_proxy": "プロキシをテスト", + "remove_proxy": "プロキシを削除", + "table": { + "proxy_url": "プロキシURL", + "requires_auth": "認証が必要" + }, + "coming_soon": "近日公開 - オープンソース(基本ローテーション)とクラウド(高度なローテーション)。インフラストラクチャを管理したくない場合は、クラウドの待機リストに参加して早期アクセスを取得してください。", + "join_waitlist": "Maxun Cloud待機リストに参加", + "alert": { + "title": "プロキシにユーザー名とパスワードが必要な場合は、必ずプロキシURLとは別に指定してください。", + "right_way": "正しい方法", + "wrong_way": "間違った方法", + "proxy_url": "プロキシURL:", + "username": "ユーザー名:", + "password": "パスワード:" + }, + "notifications": { + "config_success": "プロキシ設定が正常に送信されました", + "config_error": "プロキシ設定の送信に失敗しました。もう一度お試しください。", + "test_success": "プロキシ設定は正常に動作しています", + "test_error": "プロキシ設定のテストに失敗しました。もう一度お試しください。", + "fetch_success": "プロキシ設定の取得に成功しました", + "remove_success": "プロキシ設定が正常に削除されました", + "remove_error": "プロキシ設定の削除に失敗しました。もう一度お試しください。" + } + }, + "apikey": { + "title": "APIキーの管理", + "default_name": "Maxun APIキー", + "table": { + "name": "APIキー名", + "key": "APIキー", + "actions": "アクション" + }, + "actions": { + "copy": "コピー", + "show": "表示", + "hide": "非表示", + "delete": "削除" + }, + "no_key_message": "APIキーはまだ生成されていません。", + "generate_button": "APIキーを生成", + "notifications": { + "fetch_error": "APIキーの取得に失敗しました - ${error}", + "generate_success": "APIキーが正常に生成されました", + "generate_error": "APIキーの生成に失敗しました - ${error}", + "delete_success": "APIキーが正常に削除されました", + "delete_error": "APIキーの削除に失敗しました - ${error}", + "copy_success": "APIキーがコピーされました" + } + }, + "action_description": { + "text": { + "title": "テキストを取得", + "description": "抽出したいテキストにカーソルを合わせ、クリックして選択してください" + }, + "screenshot": { + "title": "スクリーンショットを取得", + "description": "現在のページの部分的または全体のスクリーンショットを取得します。" + }, + "list": { + "title": "リストを取得", + "description": "抽出したいリストにカーソルを合わせてください。選択後、選択したリスト内のすべてのテキストにカーソルを合わせることができます。クリックして選択してください。" + }, + "default": { + "title": "どのデータを抽出しますか?", + "description": "ロボットは一度に1つのアクションを実行するように設計されています。以下のオプションから選択できます。" + }, + "list_stages": { + "initial": "抽出したいリストとその中のテキストを選択してください", + "pagination": "ロボットがリストの残りをどのように取得するか選択してください", + "limit": "抽出するアイテムの数を選択してください", + "complete": "取得が完了しました" + } + }, + "right_panel": { + "buttons": { + "capture_list": "リストを取得", + "capture_text": "テキストを取得", + "capture_screenshot": "スクリーンショットを取得", + "confirm": "確認", + "discard": "破棄", + "confirm_capture": "取得を確認", + "confirm_pagination": "ページネーションを確認", + "confirm_limit": "制限を確認", + "finish_capture": "取得を完了", + "finish": "完了", + "cancel": "キャンセル" + }, + "screenshot": { + "capture_fullpage": "フルページを取得", + "capture_visible": "表示部分を取得", + "display_fullpage": "フルページスクリーンショットを撮影", + "display_visible": "表示部分のスクリーンショットを撮影" + }, + "pagination": { + "title": "次のリスト項目をページ上でどのように見つけますか?", + "click_next": "次へをクリックして次のページへ移動", + "click_load_more": "もっと読み込むをクリックして項目を追加", + "scroll_down": "下にスクロールして項目を追加", + "scroll_up": "上にスクロールして項目を追加", + "none": "これ以上読み込む項目はありません" + }, + "limit": { + "title": "抽出する最大行数はいくつですか?", + "custom": "カスタム", + "enter_number": "数値を入力" + }, + "fields": { + "label": "ラベル", + "data": "データ", + "field_label": "フィールドラベル", + "field_data": "フィールドデータ" + }, + "messages": { + "list_selected": "リストが正常に選択されました" + }, + "errors": { + "select_pagination": "ページネーションタイプを選択してください。", + "select_pagination_element": "まずページネーション要素を選択してください。", + "select_limit": "制限を選択するかカスタム制限を入力してください。", + "invalid_limit": "有効な制限を入力してください。", + "confirm_text_fields": "すべてのテキストフィールドを確認してください", + "unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。", + "capture_text_discarded": "テキスト取得が破棄されました", + "capture_list_discarded": "リスト取得が破棄されました" + } + }, + "save_recording": { + "title": "ロボットを保存", + "robot_name": "ロボット名", + "buttons": { + "save": "保存", + "confirm": "確認" + }, + "notifications": { + "save_success": "ロボットが正常に保存されました" + }, + "errors": { + "user_not_logged": "ユーザーがログインしていません。録画を保存できません。", + "exists_warning": "この名前のロボットは既に存在します。ロボットの上書きを確認してください。" + }, + "tooltips": { + "saving": "ワークフローを最適化して保存中" + } + }, + "browser_recording": { + "modal": { + "confirm_discard": "録画を破棄してもよろしいですか?" + }, + "notifications": { + "terminated": "現在の録画は終了しました" + } + } +} diff --git a/public/locales/zh.json b/public/locales/zh.json new file mode 100644 index 000000000..09840b6f5 --- /dev/null +++ b/public/locales/zh.json @@ -0,0 +1,225 @@ +{ + "login": { + "title": "欢迎回来!", + "email": "电子邮箱", + "password": "密码", + "button": "登录", + "loading": "加载中", + "register_prompt": "还没有账号?", + "register_link": "注册", + "welcome_notification": "欢迎使用 Maxun!", + "error_notification": "登录失败。请重试。" + }, + "register": { + "title": "注册账号", + "email": "电子邮箱", + "password": "密码", + "button": "注册", + "loading": "加载中", + "register_prompt": "已有账号?", + "login_link": "登录", + "welcome_notification": "欢迎使用 Maxun!", + "error_notification": "注册失败。请重试。" + }, + "recordingtable": { + "run": "运行", + "name": "名称", + "schedule": "计划", + "integrate": "集成", + "settings": "设置", + "options": "选项", + "heading": "我的机器人", + "new": "创建机器人", + "modal": { + "title": "输入URL", + "label": "URL", + "button": "开始录制" + }, + "edit": "编辑", + "delete": "删除", + "duplicate": "复制", + "search": "搜索机器人..." + }, + "mainmenu": { + "recordings": "机器人", + "runs": "运行记录", + "proxy": "代理", + "apikey": "API密钥", + "feedback": "加入 Maxun Cloud", + "apidocs": "API文档" + }, + "runstable": { + "runs": "所有运行记录", + "runStatus": "状态", + "runName": "名称", + "startedAt": "开始时间", + "finishedAt": "结束时间", + "delete": "删除", + "settings": "设置", + "search": "搜索运行记录..." + }, + "proxy": { + "title": "代理设置", + "tab_standard": "标准代理", + "tab_rotation": "自动代理轮换", + "server_url": "代理服务器URL", + "server_url_helper": "用于所有机器人的代理。支持HTTP和SOCKS代理。示例 http://myproxy.com:3128 或 socks5://myproxy.com:3128。简短形式 myproxy.com:3128 被视为HTTP代理。", + "requires_auth": "需要认证?", + "username": "用户名", + "password": "密码", + "add_proxy": "添加代理", + "test_proxy": "测试代理", + "remove_proxy": "删除代理", + "table": { + "proxy_url": "代理URL", + "requires_auth": "需要认证" + }, + "coming_soon": "即将推出 - 开源版(基础轮换)和云版(高级轮换)。如果您不想管理基础设施,请加入我们的云服务等候名单以获得早期访问权限。", + "join_waitlist": "加入Maxun Cloud等候名单", + "alert": { + "title": "如果您的代理需要用户名和密码,请务必将它们与代理URL分开提供。", + "right_way": "正确方式", + "wrong_way": "错误方式", + "proxy_url": "代理URL:", + "username": "用户名:", + "password": "密码:" + }, + "notifications": { + "config_success": "代理配置提交成功", + "config_error": "提交代理配置失败。请重试。", + "test_success": "代理配置运行正常", + "test_error": "测试代理配置失败。请重试。", + "fetch_success": "成功获取代理配置", + "remove_success": "成功删除代理配置", + "remove_error": "删除代理配置失败。请重试。" + } + }, + "apikey": { + "title": "管理API密钥", + "default_name": "Maxun API密钥", + "table": { + "name": "API密钥名称", + "key": "API密钥", + "actions": "操作" + }, + "actions": { + "copy": "复制", + "show": "显示", + "hide": "隐藏", + "delete": "删除" + }, + "no_key_message": "您还未生成API密钥。", + "generate_button": "生成API密钥", + "notifications": { + "fetch_error": "获取API密钥失败 - ${error}", + "generate_success": "API密钥生成成功", + "generate_error": "生成API密钥失败 - ${error}", + "delete_success": "API密钥删除成功", + "delete_error": "删除API密钥失败 - ${error}", + "copy_success": "API密钥复制成功" + } + }, + "action_description": { + "text": { + "title": "捕获文本", + "description": "将鼠标悬停在要提取的文本上并点击选择" + }, + "screenshot": { + "title": "捕获截图", + "description": "捕获当前页面的部分或全部截图。" + }, + "list": { + "title": "捕获列表", + "description": "将鼠标悬停在要提取的列表上。选择后,您可以将鼠标悬停在所选列表中的所有文本上。点击选择它们。" + }, + "default": { + "title": "您想提取什么数据?", + "description": "机器人设计为一次执行一个操作。您可以选择以下任何选项。" + }, + "list_stages": { + "initial": "选择要提取的列表及其中的文本", + "pagination": "选择机器人如何捕获列表的其余部分", + "limit": "选择要提取的项目数量", + "complete": "捕获完成" + } + }, + "right_panel": { + "buttons": { + "capture_list": "捕获列表", + "capture_text": "捕获文本", + "capture_screenshot": "捕获截图", + "confirm": "确认", + "discard": "放弃", + "confirm_capture": "确认捕获", + "confirm_pagination": "确认分页", + "confirm_limit": "确认限制", + "finish_capture": "完成捕获", + "finish": "完成", + "cancel": "取消" + }, + "screenshot": { + "capture_fullpage": "捕获整页", + "capture_visible": "捕获可见部分", + "display_fullpage": "获取整页截图", + "display_visible": "获取可见部分截图" + }, + "pagination": { + "title": "如何在页面上找到下一个列表项?", + "click_next": "点击下一页导航到下一页", + "click_load_more": "点击加载更多来加载更多项目", + "scroll_down": "向下滚动加载更多项目", + "scroll_up": "向上滚动加载更多项目", + "none": "没有更多项目可加载" + }, + "limit": { + "title": "您想要提取的最大行数是多少?", + "custom": "自定义", + "enter_number": "输入数字" + }, + "fields": { + "label": "标签", + "data": "数据", + "field_label": "字段标签", + "field_data": "字段数据" + }, + "messages": { + "list_selected": "列表选择成功" + }, + "errors": { + "select_pagination": "请选择分页类型。", + "select_pagination_element": "请先选择分页元素。", + "select_limit": "请选择限制或输入自定义限制。", + "invalid_limit": "请输入有效的限制。", + "confirm_text_fields": "请确认所有文本字段", + "unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。", + "capture_text_discarded": "文本捕获已放弃", + "capture_list_discarded": "列表捕获已放弃" + } + }, + "save_recording": { + "title": "保存机器人", + "robot_name": "机器人名称", + "buttons": { + "save": "保存", + "confirm": "确认" + }, + "notifications": { + "save_success": "机器人保存成功" + }, + "errors": { + "user_not_logged": "用户未登录。无法保存录制。", + "exists_warning": "已存在同名机器人,请确认是否覆盖机器人。" + }, + "tooltips": { + "saving": "正在优化并保存工作流程" + } + }, + "browser_recording": { + "modal": { + "confirm_discard": "您确定要放弃录制吗?" + }, + "notifications": { + "terminated": "当前录制已终止" + } + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index c37de9ea4..02dff1347 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ -import React from 'react'; -import { Routes, Route } from 'react-router-dom'; +import React from "react"; +import { Routes, Route } from "react-router-dom"; import { ThemeProvider, createTheme } from "@mui/material/styles"; import { GlobalInfoProvider } from "./context/globalInfo"; import { PageWrapper } from "./pages/PageWrappper"; +import i18n from "./i18n"; + const theme = createTheme({ palette: { @@ -20,14 +22,14 @@ const theme = createTheme({ }, containedPrimary: { // Styles for 'contained' variant with 'primary' color - '&:hover': { + "&:hover": { backgroundColor: "#ff66d9", }, }, outlined: { // Apply white background for all 'outlined' variant buttons backgroundColor: "#ffffff", - '&:hover': { + "&:hover": { backgroundColor: "#f0f0f0", // Optional lighter background on hover }, }, @@ -36,7 +38,7 @@ const theme = createTheme({ MuiLink: { styleOverrides: { root: { - '&:hover': { + "&:hover": { color: "#ff00c3", }, }, @@ -63,7 +65,7 @@ const theme = createTheme({ standardInfo: { backgroundColor: "#fce1f4", color: "#ff00c3", - '& .MuiAlert-icon': { + "& .MuiAlert-icon": { color: "#ff00c3", }, }, @@ -72,7 +74,7 @@ const theme = createTheme({ MuiAlertTitle: { styleOverrides: { root: { - '& .MuiAlert-icon': { + "& .MuiAlert-icon": { color: "#ffffff", }, }, @@ -81,15 +83,16 @@ const theme = createTheme({ }, }); - function App() { return ( <ThemeProvider theme={theme}> - <GlobalInfoProvider> - <Routes> - <Route path="/*" element={<PageWrapper />} /> - </Routes> - </GlobalInfoProvider> + + <GlobalInfoProvider> + <Routes> + <Route path="/*" element={<PageWrapper />} /> + </Routes> + </GlobalInfoProvider> + </ThemeProvider> ); } diff --git a/src/components/molecules/ActionDescriptionBox.tsx b/src/components/molecules/ActionDescriptionBox.tsx index cad962c76..190c58384 100644 --- a/src/components/molecules/ActionDescriptionBox.tsx +++ b/src/components/molecules/ActionDescriptionBox.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material'; import { useActionContext } from '../../context/browserActions'; import MaxunLogo from "../../assets/maxunlogo.png"; +import { useTranslation } from 'react-i18next'; const CustomBoxContainer = styled.div` position: relative; @@ -44,6 +45,7 @@ const Content = styled.div` `; const ActionDescriptionBox = () => { + const { t } = useTranslation(); const { getText, getScreenshot, getList, captureStage } = useActionContext() as { getText: boolean; getScreenshot: boolean; @@ -52,36 +54,36 @@ const ActionDescriptionBox = () => { }; const messages = [ - { stage: 'initial' as const, text: 'Select the list you want to extract along with the texts inside it' }, - { stage: 'pagination' as const, text: 'Select how the robot can capture the rest of the list' }, - { stage: 'limit' as const, text: 'Choose the number of items to extract' }, - { stage: 'complete' as const, text: 'Capture is complete' }, + { stage: 'initial' as const, text: t('action_description.list_stages.initial') }, + { stage: 'pagination' as const, text: t('action_description.list_stages.pagination') }, + { stage: 'limit' as const, text: t('action_description.list_stages.limit') }, + { stage: 'complete' as const, text: t('action_description.list_stages.complete') }, ]; - const stages = messages.map(({ stage }) => stage); // Create a list of stages - const currentStageIndex = stages.indexOf(captureStage); // Get the index of the current stage + const stages = messages.map(({ stage }) => stage); + const currentStageIndex = stages.indexOf(captureStage); const renderActionDescription = () => { if (getText) { return ( <> - <Typography variant="subtitle2" gutterBottom>Capture Text</Typography> - <Typography variant="body2" gutterBottom>Hover over the texts you want to extract and click to select them</Typography> + <Typography variant="subtitle2" gutterBottom>{t('action_description.text.title')}</Typography> + <Typography variant="body2" gutterBottom>{t('action_description.text.description')}</Typography> </> ); } else if (getScreenshot) { return ( <> - <Typography variant="subtitle2" gutterBottom>Capture Screenshot</Typography> - <Typography variant="body2" gutterBottom>Capture a partial or full page screenshot of the current page.</Typography> + <Typography variant="subtitle2" gutterBottom>{t('action_description.screenshot.title')}</Typography> + <Typography variant="body2" gutterBottom>{t('action_description.screenshot.description')}</Typography> </> ); } else if (getList) { return ( <> - <Typography variant="subtitle2" gutterBottom>Capture List</Typography> + <Typography variant="subtitle2" gutterBottom>{t('action_description.list.title')}</Typography> <Typography variant="body2" gutterBottom> - Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them. + {t('action_description.list.description')} </Typography> <Box> {messages.map(({ stage, text }, index) => ( @@ -89,7 +91,7 @@ const ActionDescriptionBox = () => { key={stage} control={ <Checkbox - checked={index < currentStageIndex} // Check the box if we are past this stage + checked={index < currentStageIndex} disabled /> } @@ -102,8 +104,8 @@ const ActionDescriptionBox = () => { } else { return ( <> - <Typography variant="subtitle2" gutterBottom>What data do you want to extract?</Typography> - <Typography variant="body2" gutterBottom>A robot is designed to perform one action at a time. You can choose any of the options below.</Typography> + <Typography variant="subtitle2" gutterBottom>{t('action_description.default.title')}</Typography> + <Typography variant="body2" gutterBottom>{t('action_description.default.description')}</Typography> </> ); } @@ -111,7 +113,7 @@ const ActionDescriptionBox = () => { return ( <CustomBoxContainer> - <Logo src={MaxunLogo} alt="Maxun Logo" /> + <Logo src={MaxunLogo} alt={t('common.maxun_logo')} /> <Triangle /> <Content> {renderActionDescription()} @@ -120,4 +122,4 @@ const ActionDescriptionBox = () => { ); }; -export default ActionDescriptionBox; +export default ActionDescriptionBox; \ No newline at end of file diff --git a/src/components/molecules/BrowserRecordingSave.tsx b/src/components/molecules/BrowserRecordingSave.tsx index 03758717c..e1eff20e6 100644 --- a/src/components/molecules/BrowserRecordingSave.tsx +++ b/src/components/molecules/BrowserRecordingSave.tsx @@ -5,8 +5,10 @@ import { useGlobalInfoStore } from '../../context/globalInfo'; import { stopRecording } from "../../api/recording"; import { useNavigate } from 'react-router-dom'; import { GenericModal } from "../atoms/GenericModal"; +import { useTranslation } from 'react-i18next'; const BrowserRecordingSave = () => { + const { t } = useTranslation(); const [openModal, setOpenModal] = useState<boolean>(false); const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore(); const navigate = useNavigate(); @@ -14,7 +16,7 @@ const BrowserRecordingSave = () => { const goToMainMenu = async () => { if (browserId) { await stopRecording(browserId); - notify('warning', 'Current Recording was terminated'); + notify('warning', t('browser_recording.notifications.terminated')); setBrowserId(null); } navigate('/'); @@ -25,30 +27,29 @@ const BrowserRecordingSave = () => { <Grid item xs={12} md={3} lg={3}> <div style={{ marginTop: '12px', - // marginLeft: '10px', color: 'white', position: 'absolute', background: '#ff00c3', border: 'none', borderRadius: '5px', padding: '7.5px', - width: 'calc(100% - 20px)', // Ensure it takes full width but with padding + width: 'calc(100% - 20px)', overflow: 'hidden', display: 'flex', justifyContent: 'space-between', }}> <Button onClick={() => setOpenModal(true)} variant="outlined" style={{ marginLeft: "25px" }} size="small" color="error"> - Discard + {t('right_panel.buttons.discard')} </Button> <GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}> <Box p={2}> - <Typography variant="h6">Are you sure you want to discard the recording?</Typography> + <Typography variant="h6">{t('browser_recording.modal.confirm_discard')}</Typography> <Box display="flex" justifyContent="space-between" mt={2}> <Button onClick={goToMainMenu} variant="contained" color="error"> - Discard + {t('right_panel.buttons.discard')} </Button> <Button onClick={() => setOpenModal(false)} variant="outlined"> - Cancel + {t('right_panel.buttons.cancel')} </Button> </Box> </Box> @@ -60,7 +61,7 @@ const BrowserRecordingSave = () => { ); } -export default BrowserRecordingSave +export default BrowserRecordingSave; const modalStyle = { top: '25%', diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index c2f271cf9..0658a7668 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -1,10 +1,24 @@ + + + + + + + + + + + + +import { useTranslation } from "react-i18next"; // Import useTranslation hook + import React, { useState, useContext, useEffect } from 'react'; import axios from 'axios'; import styled from "styled-components"; import { stopRecording } from "../../api/recording"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar } from "@mui/material"; -import { AccountCircle, Logout, Clear, YouTube, X, Update, Close } from "@mui/icons-material"; +import { AccountCircle, Logout, Clear, YouTube, X, Update, Close,Language } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../../context/auth'; import { SaveRecording } from '../molecules/SaveRecording'; @@ -13,18 +27,26 @@ import { apiUrl } from '../../apiConfig'; import MaxunLogo from "../../assets/maxunlogo.png"; import packageJson from "../../../package.json" + interface NavBarProps { recordingName: string; isRecording: boolean; } -export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) => { - const { notify, browserId, setBrowserId, recordingUrl } = useGlobalInfoStore(); +export const NavBar: React.FC<NavBarProps> = ({ + recordingName, + isRecording, +}) => { + const { notify, browserId, setBrowserId } = useGlobalInfoStore(); const { state, dispatch } = useContext(AuthContext); const { user } = state; const navigate = useNavigate(); + const { t, i18n } = useTranslation(); // Get translation function and i18n methods const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + + const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null); + const currentVersion = packageJson.version; const [open, setOpen] = useState(false); @@ -58,29 +80,40 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) => setTab(newValue); }; + const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { setAnchorEl(event.currentTarget); }; + const handleLangMenuOpen = (event: React.MouseEvent<HTMLElement>) => { + setLangAnchorEl(event.currentTarget); + }; + const handleMenuClose = () => { setAnchorEl(null); + setLangAnchorEl(null); }; const logout = async () => { - dispatch({ type: 'LOGOUT' }); - window.localStorage.removeItem('user'); + dispatch({ type: "LOGOUT" }); + window.localStorage.removeItem("user"); const { data } = await axios.get(`${apiUrl}/auth/logout`); - notify('success', data.message); - navigate('/login'); + notify("success", data.message); + navigate("/login"); }; const goToMainMenu = async () => { if (browserId) { await stopRecording(browserId); - notify('warning', 'Current Recording was terminated'); + notify("warning", "Current Recording was terminated"); setBrowserId(null); } - navigate('/'); + navigate("/"); + }; + + const changeLanguage = (lang: string) => { + i18n.changeLanguage(lang); // Change language dynamically + localStorage.setItem("language", lang); // Persist language to localStorage }; useEffect(() => { @@ -95,228 +128,288 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) => }, []); return ( - <> - {isUpdateAvailable && ( - <Snackbar - open={isUpdateAvailable} - onClose={() => setIsUpdateAvailable(false)} - message={ - `New version ${latestVersion} available! Click "Upgrade" to update.` - } - action={ + + <NavBarWrapper> + + <div + style={{ + display: "flex", + justifyContent: "flex-start", + }} + > + <img + src={MaxunLogo} + width={45} + height={40} + style={{ borderRadius: "5px", margin: "5px 0px 5px 15px" }} + /> + <div style={{ padding: "11px" }}> + <ProjectName>Maxun</ProjectName> + </div> + <Chip + label="beta" + color="primary" + variant="outlined" + sx={{ marginTop: "10px" }} + /> + + + </div> + {user ? ( + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + }} + > + {!isRecording ? ( <> - <Button - color="primary" - size="small" - onClick={handleUpdateOpen} - style={{ - backgroundColor: '#ff00c3', - color: 'white', - fontWeight: 'bold', - textTransform: 'none', - marginRight: '8px', - borderRadius: '5px', + <IconButton + component="a" + href="https://discord.gg/5GbPjBUkws" + target="_blank" + rel="noopener noreferrer" + sx={{ + display: "flex", + alignItems: "center", + borderRadius: "5px", + padding: "8px", + marginRight: "30px", }} > - Upgrade - </Button> + <DiscordIcon sx={{ marginRight: "5px" }} /> + </IconButton> + <iframe + src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" + frameBorder="0" + scrolling="0" + width="170" + height="30" + title="GitHub" + ></iframe> <IconButton - size="small" - aria-label="close" - color="inherit" - onClick={() => setIsUpdateAvailable(false)} - style={{ color: 'black' }} + onClick={handleMenuOpen} + sx={{ + display: "flex", + alignItems: "center", + borderRadius: "5px", + padding: "8px", + marginRight: "10px", + "&:hover": { backgroundColor: "white", color: "#ff00c3" }, + }} > - <Close /> + <AccountCircle sx={{ marginRight: "5px" }} /> + <Typography variant="body1">{user.email}</Typography> </IconButton> + <Menu + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleMenuClose} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + <MenuItem + onClick={() => { + handleMenuClose(); + logout(); + }} + > + <Logout sx={{ marginRight: "5px" }} /> {t("logout")} + </MenuItem> + </Menu> + </> - } - ContentProps={{ - sx: { - background: "white", - color: "black", - } + ) : ( + <> + <IconButton + onClick={goToMainMenu} + sx={{ + borderRadius: "5px", + padding: "8px", + background: "red", + color: "white", + marginRight: "10px", + "&:hover": { color: "white", backgroundColor: "red" }, + }} + > + <Clear sx={{ marginRight: "5px" }} /> + {t("discard")} + </IconButton> + <SaveRecording fileName={recordingName} /> + </> + )} + <IconButton + onClick={handleLangMenuOpen} + sx={{ + display: "flex", + alignItems: "center", + borderRadius: "5px", + padding: "8px", + marginRight: "10px", + }} + > + <Typography variant="body1"> + <Language /> + </Typography> + </IconButton> + <Menu + anchorEl={langAnchorEl} + open={Boolean(langAnchorEl)} + onClose={handleMenuClose} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + <MenuItem + onClick={() => { + changeLanguage("en"); + handleMenuClose(); + }} + > + English + </MenuItem> + <MenuItem + onClick={() => { + changeLanguage("es"); + handleMenuClose(); + }} + > + Español + </MenuItem> + <MenuItem + onClick={() => { + changeLanguage("ja"); + handleMenuClose(); + }} + > + 日本語 + </MenuItem> + {/* <MenuItem + onClick={() => { + changeLanguage("ar"); + handleMenuClose(); + }} + > + العربية + </MenuItem> */} + <MenuItem + onClick={() => { + changeLanguage("zh"); + handleMenuClose(); + }} + > + 中文 + </MenuItem> + <MenuItem + onClick={() => { + changeLanguage("de"); + handleMenuClose(); + }} + > + Deutsch + </MenuItem> + </Menu> + </div> + ) : ( + <><IconButton + onClick={handleLangMenuOpen} + sx={{ + display: "flex", + alignItems: "center", + borderRadius: "5px", + padding: "8px", + marginRight: "10px", + }} + > + <Typography variant="body1">{t("language")}</Typography> + </IconButton> + <Menu + anchorEl={langAnchorEl} + open={Boolean(langAnchorEl)} + onClose={handleMenuClose} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + <MenuItem + onClick={() => { + changeLanguage("en"); + handleMenuClose(); + }} + > + English + </MenuItem> + <MenuItem + onClick={() => { + changeLanguage("es"); + handleMenuClose(); + }} + > + Español + </MenuItem> + <MenuItem + onClick={() => { + changeLanguage("ja"); + handleMenuClose(); + }} + > + 日本語 + </MenuItem> + {/* <MenuItem + onClick={() => { + changeLanguage("ar"); + handleMenuClose(); + }} + > + العربية + </MenuItem> */} + <MenuItem + onClick={() => { + changeLanguage("zh"); + handleMenuClose(); + }} + > + 中文 + </MenuItem> + <MenuItem + onClick={() => { + changeLanguage("de"); + handleMenuClose(); }} - /> - + > + Deutsch + </MenuItem> + </Menu></> )} - <NavBarWrapper> - <div style={{ - display: 'flex', - justifyContent: 'flex-start', - }}> - <img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} /> - <div style={{ padding: '11px' }}><ProjectName>Maxun</ProjectName></div> - <Chip - label={`${currentVersion}`} - color="primary" - variant="outlined" - sx={{ marginTop: '10px' }} - /> - </div> - { - user ? ( - <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}> - {!isRecording ? ( - <> - <Button variant="outlined" onClick={handleUpdateOpen} sx={{ - marginRight: '40px', - color: "#00000099", - border: "#00000099 1px solid", - '&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' } - }}> - <Update sx={{ marginRight: '5px' }} /> Upgrade Maxun - </Button> - <Modal open={open} onClose={handleUpdateClose}> - <Box - sx={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - width: 500, - bgcolor: "background.paper", - boxShadow: 24, - p: 4, - borderRadius: 2, - }} - > - {latestVersion === null ? ( - <Typography>Checking for updates...</Typography> - ) : currentVersion === latestVersion ? ( - <Typography variant="h6" textAlign="center"> - 🎉 You're up to date! - </Typography> - ) : ( - <> - <Typography variant="body1" textAlign="left" sx={{ marginLeft: '30px' }}> - A new version is available: {latestVersion}. Upgrade to the latest version for bug fixes, enhancements and new features! - <br /> - View all the new updates - <a href="https://github.com/getmaxun/maxun/releases/" target="_blank" style={{ textDecoration: 'none' }}>{' '}here.</a> - </Typography> - <Tabs - value={tab} - onChange={handleUpdateTabChange} - sx={{ marginTop: 2, marginBottom: 2 }} - centered - > - <Tab label="Manual Setup Upgrade" /> - <Tab label="Docker Compose Setup Upgrade" /> - </Tabs> - {tab === 0 && ( - <Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}> - <code style={{ color: 'black' }}> - <p>Run the commands below</p> - # pull latest changes - <br /> - git pull origin master - <br /> - <br /> - # install dependencies - <br /> - npm install - <br /> - <br /> - # start maxun - <br /> - npm run start - </code> - </Box> - )} - {tab === 1 && ( - <Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}> - <code style={{ color: 'black' }}> - <p>Run the commands below</p> - # pull latest docker images - <br /> - docker-compose pull - <br /> - <br /> - # start maxun - <br /> - docker-compose up -d - </code> - </Box> - )} - </> - )} - </Box> - </Modal> - <iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe> - <IconButton onClick={handleMenuOpen} sx={{ - display: 'flex', - alignItems: 'center', - borderRadius: '5px', - padding: '8px', - marginRight: '10px', - '&:hover': { backgroundColor: 'white', color: '#ff00c3' } - }}> - <AccountCircle sx={{ marginRight: '5px' }} /> - <Typography variant="body1">{user.email}</Typography> - </IconButton> - <Menu - anchorEl={anchorEl} - open={Boolean(anchorEl)} - onClose={handleMenuClose} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - PaperProps={{ sx: { width: '180px' } }} - > - <MenuItem onClick={() => { handleMenuClose(); logout(); }}> - <Logout sx={{ marginRight: '5px' }} /> Logout - </MenuItem> - <MenuItem onClick={() => { - window.open('https://discord.gg/5GbPjBUkws', '_blank'); - }}> - <DiscordIcon sx={{ marginRight: '5px' }} /> Discord - </MenuItem> - <MenuItem onClick={() => { - window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank'); - }}> - <YouTube sx={{ marginRight: '5px' }} /> YouTube - </MenuItem> - <MenuItem onClick={() => { - window.open('https://x.com/maxun_io?ref=app', '_blank'); - }}> - <X sx={{ marginRight: '5px' }} /> Twiiter (X) - </MenuItem> - </Menu> - </> - ) : ( - <> - <IconButton onClick={goToMainMenu} sx={{ - borderRadius: '5px', - padding: '8px', - background: 'red', - color: 'white', - marginRight: '10px', - '&:hover': { color: 'white', backgroundColor: 'red' } - }}> - <Clear sx={{ marginRight: '5px' }} /> - Discard - </IconButton> - <SaveRecording fileName={recordingName} /> - </> - )} - </div> - ) : "" - } - </NavBarWrapper> - </> + + + </NavBarWrapper> + + ); }; const NavBarWrapper = styled.div` grid-area: navbar; background-color: white; - padding:5px; + padding: 5px; display: flex; justify-content: space-between; border-bottom: 1px solid #e0e0e0; diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 651d3677f..92bf572a4 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -19,6 +20,7 @@ import { useNavigate } from 'react-router-dom'; import { stopRecording } from "../../api/recording"; import { GenericModal } from '../atoms/GenericModal'; + /** TODO: * 1. allow editing existing robot after persisting browser steps */ @@ -31,30 +33,9 @@ interface Column { format?: (value: string) => string; } -const columns: readonly Column[] = [ - { id: 'interpret', label: 'Run', minWidth: 80 }, - { id: 'name', label: 'Name', minWidth: 80 }, - { - id: 'schedule', - label: 'Schedule', - minWidth: 80, - }, - { - id: 'integrate', - label: 'Integrate', - minWidth: 80, - }, - { - id: 'settings', - label: 'Settings', - minWidth: 80, - }, - { - id: 'options', - label: 'Options', - minWidth: 80, - }, -]; + + + interface Data { id: string; @@ -76,12 +57,38 @@ interface RecordingsTableProps { } export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => { + const {t} = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rows, setRows] = React.useState<Data[]>([]); const [isModalOpen, setModalOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); + const columns: readonly Column[] = [ + { id: 'interpret', label: t('recordingtable.run'), minWidth: 80 }, + { id: 'name', label: t('recordingtable.name'), minWidth: 80 }, + { + id: 'schedule', + label: t('recordingtable.schedule'), + minWidth: 80, + }, + { + id: 'integrate', + label: t('recordingtable.integrate'), + minWidth: 80, + }, + { + id: 'settings', + label: t('recordingtable.settings'), + minWidth: 80, + }, + { + id: 'options', + label: t('recordingtable.options'), + minWidth: 80, + }, + ]; + const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); const navigate = useNavigate(); @@ -151,16 +158,17 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl row.name.toLowerCase().includes(searchTerm.toLowerCase()) ); + return ( <React.Fragment> <Box display="flex" justifyContent="space-between" alignItems="center"> <Typography variant="h6" gutterBottom> - My Robots + {t('recordingtable.heading')} </Typography> <Box display="flex" alignItems="center" gap={2}> <TextField size="small" - placeholder="Search robots..." + placeholder={t('recordingtable.search')} value={searchTerm} onChange={handleSearchChange} InputProps={{ @@ -187,7 +195,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl '&:hover': { color: 'white', backgroundColor: '#ff00c3' } }} > - <Add sx={{ marginRight: '5px' }} /> Create Robot + <Add sx={{ marginRight: '5px' }} /> {t('recordingtable.new')} </IconButton> </Box> </Box> @@ -297,9 +305,9 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl /> <GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}> <div style={{ padding: '20px' }}> - <Typography variant="h6" gutterBottom>Enter URL To Extract Data</Typography> + <Typography variant="h6" gutterBottom>{t('recordingtable.modal.title')}</Typography> <TextField - label="URL" + label={t('recordingtable.modal.label')} variant="outlined" fullWidth value={recordingUrl} @@ -312,7 +320,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl onClick={startRecording} disabled={!recordingUrl} > - Start Training Robot + {t('recordingtable.modal.button')} </Button> </div> </GenericModal> @@ -397,6 +405,8 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut setAnchorEl(null); }; + const {t} = useTranslation(); + return ( <> <IconButton @@ -415,20 +425,23 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut <ListItemIcon> <Edit fontSize="small" /> </ListItemIcon> - <ListItemText>Edit</ListItemText> + <ListItemText>{t('recordingtable.edit')}</ListItemText> </MenuItem> - <MenuItem onClick={() => { handleDuplicate(); handleClose(); }}> + + <MenuItem onClick={() => { handleDelete(); handleClose(); }}> <ListItemIcon> - <ContentCopy fontSize="small" /> + <DeleteForever fontSize="small" /> </ListItemIcon> - <ListItemText>Duplicate</ListItemText> + <ListItemText>{t('recordingtable.delete')}</ListItemText> </MenuItem> - <MenuItem onClick={() => { handleDelete(); handleClose(); }}> + + <MenuItem onClick={() => { handleDuplicate(); handleClose(); }}> <ListItemIcon> - <DeleteForever fontSize="small" /> + <ContentCopy fontSize="small" /> </ListItemIcon> - <ListItemText>Delete</ListItemText> + <ListItemText>{t('recordingtable.duplicate')}</ListItemText> </MenuItem> + </Menu> </> ); diff --git a/src/components/molecules/RunsTable.tsx b/src/components/molecules/RunsTable.tsx index 669cecd61..41dd047a3 100644 --- a/src/components/molecules/RunsTable.tsx +++ b/src/components/molecules/RunsTable.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import { useEffect, useState } from "react"; +import { useTranslation } from 'react-i18next'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -7,14 +9,24 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; -import { useEffect, useState } from "react"; +import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import SearchIcon from '@mui/icons-material/Search'; + import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRuns } from "../../api/storage"; import { RunSettings } from "./RunSettings"; import { CollapsibleRow } from "./ColapsibleRow"; -import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import SearchIcon from '@mui/icons-material/Search'; + +// Export columns before the component +export const columns: readonly Column[] = [ + { id: 'runStatus', label: 'Status', minWidth: 80 }, + { id: 'name', label: 'Name', minWidth: 80 }, + { id: 'startedAt', label: 'Started At', minWidth: 80 }, + { id: 'finishedAt', label: 'Finished At', minWidth: 80 }, + { id: 'settings', label: 'Settings', minWidth: 80 }, + { id: 'delete', label: 'Delete', minWidth: 80 }, +]; interface Column { id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings'; @@ -24,16 +36,7 @@ interface Column { format?: (value: string) => string; } -export const columns: readonly Column[] = [ - { id: 'runStatus', label: 'Status', minWidth: 80 }, - { id: 'name', label: 'Robot Name', minWidth: 80 }, - { id: 'startedAt', label: 'Started at', minWidth: 80 }, - { id: 'finishedAt', label: 'Finished at', minWidth: 80 }, - { id: 'settings', label: 'Settings', minWidth: 80 }, - { id: 'delete', label: 'Delete', minWidth: 80 }, -]; - -export interface Data { +interface Data { id: number; status: string; name: string; @@ -58,15 +61,25 @@ interface RunsTableProps { runningRecordingName: string; } -export const RunsTable = ( - { currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsTableProps) => { +export const RunsTable: React.FC<RunsTableProps> = ({ + currentInterpretationLog, + abortRunHandler, + runId, + runningRecordingName +}) => { + const { t } = useTranslation(); + + // Update column labels using translation if needed + const translatedColumns = columns.map(column => ({ + ...column, + label: t(`runstable.${column.id}`, column.label) + })); + const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [rows, setRows] = useState<Data[]>([]); - const [searchTerm, setSearchTerm] = useState(''); - const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); const handleChangePage = (event: unknown, newPage: number) => { @@ -86,16 +99,13 @@ export const RunsTable = ( const fetchRuns = async () => { const runs = await getStoredRuns(); if (runs) { - const parsedRows: Data[] = []; - runs.map((run: any, index) => { - parsedRows.push({ - id: index, - ...run, - }); - }); + const parsedRows: Data[] = runs.map((run: any, index: number) => ({ + id: index, + ...run, + })); setRows(parsedRows); } else { - notify('error', 'No runs found. Please try again.') + notify('error', 'No runs found. Please try again.'); } }; @@ -104,7 +114,7 @@ export const RunsTable = ( fetchRuns(); setRerenderRuns(false); } - }, [rerenderRuns]); + }, [rerenderRuns, rows.length, setRerenderRuns]); const handleDelete = () => { setRows([]); @@ -112,7 +122,6 @@ export const RunsTable = ( fetchRuns(); }; - // Filter rows based on search term const filteredRows = rows.filter((row) => row.name.toLowerCase().includes(searchTerm.toLowerCase()) @@ -120,7 +129,6 @@ export const RunsTable = ( // Group filtered rows by robot meta id const groupedRows = filteredRows.reduce((acc, row) => { - if (!acc[row.robotMetaId]) { acc[row.robotMetaId] = []; } @@ -132,11 +140,11 @@ export const RunsTable = ( <React.Fragment> <Box display="flex" justifyContent="space-between" alignItems="center" mb={2}> <Typography variant="h6" gutterBottom> - All Runs + {t('runstable.runs', 'Runs')} </Typography> <TextField size="small" - placeholder="Search runs..." + placeholder={t('runstable.search', 'Search runs...')} value={searchTerm} onChange={handleSearchChange} InputProps={{ @@ -149,16 +157,14 @@ export const RunsTable = ( {Object.entries(groupedRows).map(([id, data]) => ( <Accordion key={id}> <AccordionSummary expandIcon={<ExpandMoreIcon />}> - <Typography variant="h6">{data[data.length - 1].name}</Typography> - </AccordionSummary> <AccordionDetails> <Table stickyHeader aria-label="sticky table"> <TableHead> <TableRow> <TableCell /> - {columns.map((column) => ( + {translatedColumns.map((column) => ( <TableCell key={column.id} align={column.align} @@ -200,4 +206,4 @@ export const RunsTable = ( /> </React.Fragment> ); -}; +}; \ No newline at end of file diff --git a/src/components/molecules/SaveRecording.tsx b/src/components/molecules/SaveRecording.tsx index cfebc867b..8e1eb462e 100644 --- a/src/components/molecules/SaveRecording.tsx +++ b/src/components/molecules/SaveRecording.tsx @@ -9,13 +9,14 @@ import { TextField, Typography } from "@mui/material"; import { WarningText } from "../atoms/texts"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; interface SaveRecordingProps { fileName: string; } export const SaveRecording = ({ fileName }: SaveRecordingProps) => { - + const { t } = useTranslation(); const [openModal, setOpenModal] = useState<boolean>(false); const [needConfirm, setNeedConfirm] = useState<boolean>(false); const [recordingName, setRecordingName] = useState<string>(fileName); @@ -46,7 +47,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { }; const exitRecording = useCallback(async () => { - notify('success', 'Robot saved successfully'); + notify('success', t('save_recording.notifications.save_success')); if (browserId) { await stopRecording(browserId); } @@ -63,7 +64,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { setWaitingForSave(true); console.log(`Saving the recording as ${recordingName} for userId ${user.id}`); } else { - console.error('User not logged in. Cannot save recording.'); + console.error(t('save_recording.notifications.user_not_logged')); } }; @@ -77,34 +78,38 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { return ( <div> <Button onClick={() => setOpenModal(true)} variant="outlined" sx={{ marginRight: '20px' }} size="small" color="success"> - Finish + {t('right_panel.buttons.finish')} </Button> <GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}> <form onSubmit={handleSaveRecording} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}> - <Typography variant="h6">Save Robot</Typography> + <Typography variant="h6">{t('save_recording.title')}</Typography> <TextField required sx={{ width: '300px', margin: '15px 0px' }} onChange={handleChangeOfTitle} id="title" - label="Robot Name" + label={t('save_recording.robot_name')} variant="outlined" defaultValue={recordingName ? recordingName : null} /> {needConfirm ? (<React.Fragment> - <Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>Confirm</Button> + <Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}> + {t('save_recording.buttons.confirm')} + </Button> <WarningText> <NotificationImportantIcon color="warning" /> - Robot with this name already exists, please confirm the Robot's overwrite. + {t('save_recording.warnings.robot_exists')} </WarningText> </React.Fragment>) - : <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>Save</Button> + : <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}> + {t('save_recording.buttons.save')} + </Button> } {waitingForSave && - <Tooltip title='Optimizing and saving the workflow' placement={"bottom"}> + <Tooltip title={t('save_recording.tooltips.optimizing')} placement={"bottom"}> <Box sx={{ width: '100%', marginTop: '10px' }}> <LinearProgress /> </Box> diff --git a/src/components/organisms/ApiKey.tsx b/src/components/organisms/ApiKey.tsx index e6a00a914..37a72764b 100644 --- a/src/components/organisms/ApiKey.tsx +++ b/src/components/organisms/ApiKey.tsx @@ -19,6 +19,7 @@ import styled from 'styled-components'; import axios from 'axios'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { apiUrl } from '../../apiConfig'; +import { useTranslation } from 'react-i18next'; const Container = styled(Box)` display: flex; @@ -29,24 +30,21 @@ const Container = styled(Box)` `; const ApiKeyManager = () => { + const { t } = useTranslation(); const [apiKey, setApiKey] = useState<string | null>(null); - const [apiKeyName, setApiKeyName] = useState<string>('Maxun API Key'); + const [apiKeyName, setApiKeyName] = useState<string>(t('apikey.default_name')); const [loading, setLoading] = useState<boolean>(true); const [showKey, setShowKey] = useState<boolean>(false); const [copySuccess, setCopySuccess] = useState<boolean>(false); const { notify } = useGlobalInfoStore(); - - - - useEffect(() => { const fetchApiKey = async () => { try { const { data } = await axios.get(`${apiUrl}/auth/api-key`); setApiKey(data.api_key); } catch (error: any) { - notify('error', `Failed to fetch API Key - ${error.message}`); + notify('error', t('apikey.notifications.fetch_error', { error: error.message })); } finally { setLoading(false); } @@ -62,9 +60,9 @@ const ApiKeyManager = () => { const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); setApiKey(data.api_key); - notify('success', `Generated API Key successfully`); + notify('success', t('apikey.notifications.generate_success')); } catch (error: any) { - notify('error', `Failed to generate API Key - ${error.message}`); + notify('error', t('apikey.notifications.generate_error', { error: error.message })); } finally { setLoading(false); } @@ -75,9 +73,9 @@ const ApiKeyManager = () => { try { await axios.delete(`${apiUrl}/auth/delete-api-key`); setApiKey(null); - notify('success', 'API Key deleted successfully'); + notify('success', t('apikey.notifications.delete_success')); } catch (error: any) { - notify('error', `Failed to delete API Key - ${error.message}`); + notify('error', t('apikey.notifications.delete_error', { error: error.message })); } finally { setLoading(false); } @@ -88,7 +86,7 @@ const ApiKeyManager = () => { navigator.clipboard.writeText(apiKey); setCopySuccess(true); setTimeout(() => setCopySuccess(false), 2000); - notify('info', 'Copied API Key successfully'); + notify('info', t('apikey.notifications.copy_success')); } }; @@ -111,16 +109,16 @@ const ApiKeyManager = () => { return ( <Container sx={{ alignSelf: 'flex-start' }}> <Typography variant="h6" gutterBottom component="div" style={{ marginBottom: '20px' }}> - Manage Your API Key + {t('apikey.title')} </Typography> {apiKey ? ( <TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}> <Table> <TableHead> <TableRow> - <TableCell>API Key Name</TableCell> - <TableCell>API Key</TableCell> - <TableCell>Actions</TableCell> + <TableCell>{t('apikey.table.name')}</TableCell> + <TableCell>{t('apikey.table.key')}</TableCell> + <TableCell>{t('apikey.table.actions')}</TableCell> </TableRow> </TableHead> <TableBody> @@ -128,17 +126,17 @@ const ApiKeyManager = () => { <TableCell>{apiKeyName}</TableCell> <TableCell>{showKey ? `${apiKey?.substring(0, 10)}...` : '***************'}</TableCell> <TableCell> - <Tooltip title="Copy"> + <Tooltip title={t('apikey.actions.copy')}> <IconButton onClick={copyToClipboard}> <ContentCopy /> </IconButton> </Tooltip> - <Tooltip title={showKey ? 'Hide' : 'Show'}> + <Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}> <IconButton onClick={() => setShowKey(!showKey)}> <Visibility /> </IconButton> </Tooltip> - <Tooltip title="Delete"> + <Tooltip title={t('apikey.actions.delete')}> <IconButton onClick={deleteApiKey} color="error"> <Delete /> </IconButton> @@ -150,9 +148,9 @@ const ApiKeyManager = () => { </TableContainer> ) : ( <> - <Typography>You haven't generated an API key yet.</Typography> + <Typography>{t('apikey.no_key_message')}</Typography> <Button onClick={generateApiKey} variant="contained" color="primary" sx={{ marginTop: '15px' }}> - Generate API Key + {t('apikey.generate_button')} </Button> </> )} diff --git a/src/components/organisms/MainMenu.tsx b/src/components/organisms/MainMenu.tsx index dadb6731c..4143ae9f3 100644 --- a/src/components/organisms/MainMenu.tsx +++ b/src/components/organisms/MainMenu.tsx @@ -5,6 +5,9 @@ import Box from '@mui/material/Box'; import { Paper, Button } from "@mui/material"; import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Code } from "@mui/icons-material"; import { apiUrl } from "../../apiConfig"; +import { useTranslation } from 'react-i18next'; +import i18n from '../../i18n'; + interface MainMenuProps { value: string; @@ -12,6 +15,7 @@ interface MainMenuProps { } export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => { + const {t} = useTranslation(); const handleChange = (event: React.SyntheticEvent, newValue: string) => { handleChangeContent(newValue); @@ -47,7 +51,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu fontSize: 'medium', }} value="recordings" - label="Robots" + label={t('mainmenu.recordings')} icon={<AutoAwesome />} iconPosition="start" /> @@ -58,7 +62,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu fontSize: 'medium', }} value="runs" - label="Runs" + label={t('mainmenu.runs')} icon={<FormatListBulleted />} iconPosition="start" /> @@ -69,7 +73,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu fontSize: 'medium', }} value="proxy" - label="Proxy" + label={t('mainmenu.proxy')} icon={<Usb />} iconPosition="start" /> @@ -80,7 +84,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu fontSize: 'medium', }} value="apikey" - label="API Key" + label={t('mainmenu.apikey')} icon={<VpnKey />} iconPosition="start" /> @@ -88,10 +92,10 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu <hr /> <Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}> <Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}> - Website To API + {t('mainmenu.apidocs')} </Button> <Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}> - Join Maxun Cloud + {t('mainmenu.feedback')} </Button> </Box> </Box> diff --git a/src/components/organisms/ProxyForm.tsx b/src/components/organisms/ProxyForm.tsx index a581144ba..8fbf730af 100644 --- a/src/components/organisms/ProxyForm.tsx +++ b/src/components/organisms/ProxyForm.tsx @@ -3,6 +3,7 @@ import { styled } from '@mui/system'; import { Alert, AlertTitle, TextField, Button, Switch, FormControlLabel, Box, Typography, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableBody, TableCell, Paper } from '@mui/material'; import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy'; import { useGlobalInfoStore } from '../../context/globalInfo'; +import { useTranslation } from 'react-i18next'; const FormContainer = styled(Box)({ display: 'flex', @@ -16,6 +17,7 @@ const FormControl = styled(Box)({ }); const ProxyForm: React.FC = () => { + const { t } = useTranslation(); const [proxyConfigForm, setProxyConfigForm] = useState({ server_url: '', username: '', @@ -79,9 +81,9 @@ const ProxyForm: React.FC = () => { try { const response = await sendProxyConfig(proxyConfigForm); if (response) { - notify('success', 'Proxy configuration submitted successfully'); + notify('success', t('proxy.notifications.config_success')); } else { - notify('error', `Failed to submit proxy configuration. Try again. ${response}`); + notify('error', t('proxy.notifications.config_error')); console.log(`Failed to submit proxy configuration. Try again. ${response}`) } } catch (error: any) { @@ -96,9 +98,9 @@ const ProxyForm: React.FC = () => { const testProxy = async () => { await testProxyConfig().then((response) => { if (response.success) { - notify('success', 'Proxy configuration is working'); + notify('success', t('proxy.notifications.test_success')); } else { - notify('error', 'Failed to test proxy configuration. Try again.'); + notify('error', t('proxy.notifications.test_error')); } }); }; @@ -109,7 +111,7 @@ const ProxyForm: React.FC = () => { if (response.proxy_url) { setIsProxyConfigured(true); setProxy(response); - notify('success', 'Proxy configuration fetched successfully'); + notify('success', t('proxy.notifications.fetch_success')); } } catch (error: any) { notify('error', error); @@ -119,11 +121,11 @@ const ProxyForm: React.FC = () => { const removeProxy = async () => { await deleteProxyConfig().then((response) => { if (response) { - notify('success', 'Proxy configuration removed successfully'); + notify('success', t('proxy.notifications.remove_success')); setIsProxyConfigured(false); setProxy({ proxy_url: '', auth: false }); } else { - notify('error', 'Failed to remove proxy configuration. Try again.'); + notify('error', t('proxy.notifications.remove_error')); } }); } @@ -136,11 +138,11 @@ const ProxyForm: React.FC = () => { <> <FormContainer> <Typography variant="h6" gutterBottom component="div" style={{ marginTop: '20px' }}> - Proxy Configuration + {t('proxy.title')} </Typography> <Tabs value={tabIndex} onChange={handleTabChange}> - <Tab label="Standard Proxy" /> - <Tab label="Automatic Proxy Rotation" /> + <Tab label={t('proxy.tab_standard')} /> + <Tab label={t('proxy.tab_rotation')} /> </Tabs> {tabIndex === 0 && ( isProxyConfigured ? ( @@ -149,8 +151,8 @@ const ProxyForm: React.FC = () => { <Table> <TableHead> <TableRow> - <TableCell><strong>Proxy URL</strong></TableCell> - <TableCell><strong>Requires Authentication</strong></TableCell> + <TableCell><strong>{t('proxy.table.proxy_url')}</strong></TableCell> + <TableCell><strong>{t('proxy.table.requires_auth')}</strong></TableCell> </TableRow> </TableHead> <TableBody> @@ -162,39 +164,37 @@ const ProxyForm: React.FC = () => { </Table> </TableContainer> <Button variant="outlined" color="primary" onClick={testProxy}> - Test Proxy + {t('proxy.test_proxy')} </Button> <Button variant="outlined" color="error" onClick={removeProxy} sx={{ marginLeft: '10px' }}> - Remove Proxy + {t('proxy.remove_proxy')} </Button> </Box> ) : ( <Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 400, width: '100%' }}> <FormControl> <TextField - label="Proxy Server URL" + label={t('proxy.server_url')} name="server_url" value={proxyConfigForm.server_url} onChange={handleChange} fullWidth required error={!!errors.server_url} - helperText={errors.server_url || `Proxy to be used for all robots. HTTP and SOCKS proxies are supported. - Example http://myproxy.com:3128 or socks5://myproxy.com:3128. - Short form myproxy.com:3128 is considered an HTTP proxy.`} + helperText={errors.server_url || t('proxy.server_url_helper')} /> </FormControl> <FormControl> <FormControlLabel control={<Switch checked={requiresAuth} onChange={handleAuthToggle} />} - label="Requires Authentication?" + label={t('proxy.requires_auth')} /> </FormControl> {requiresAuth && ( <> <FormControl> <TextField - label="Username" + label={t('proxy.username')} name="username" value={proxyConfigForm.username} onChange={handleChange} @@ -206,7 +206,7 @@ const ProxyForm: React.FC = () => { </FormControl> <FormControl> <TextField - label="Password" + label={t('proxy.password')} name="password" value={proxyConfigForm.password} onChange={handleChange} @@ -226,7 +226,7 @@ const ProxyForm: React.FC = () => { fullWidth disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))} > - Add Proxy + {t('proxy.add_proxy')} </Button> </Box> ))} @@ -234,33 +234,33 @@ const ProxyForm: React.FC = () => { <Box sx={{ maxWidth: 400, width: '100%', textAlign: 'center', marginTop: '20px' }}> <> <Typography variant="body1" gutterBottom component="div"> - Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access. + {t('proxy.coming_soon')} </Typography> <Button variant="contained" color="primary" sx={{ marginTop: '20px' }}> - <a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">Join Maxun Cloud Waitlist</a> + <a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">{t('proxy.join_waitlist')}</a> </Button> </> </Box> )} </FormContainer> <Alert severity="info" sx={{ marginTop: '80px', marginLeft: '50px', height: '230px', width: '450px', border: '1px solid #ff00c3' }}> - <AlertTitle>If your proxy requires a username and password, always provide them separately from the proxy URL. </AlertTitle> + <AlertTitle>{t('proxy.alert.title')}</AlertTitle> <br /> - <b>The right way</b> + <b>{t('proxy.alert.right_way')}</b> <br /> - Proxy URL: http://proxy.com:1337 + {t('proxy.alert.proxy_url')} http://proxy.com:1337 <br /> - Username: myusername + {t('proxy.alert.username')} myusername <br /> - Password: mypassword + {t('proxy.alert.password')} mypassword <br /> <br /> - <b>The wrong way</b> + <b>{t('proxy.alert.wrong_way')}</b> <br /> - Proxy URL: http://myusername:mypassword@proxy.com:1337 + {t('proxy.alert.proxy_url')} http://myusername:mypassword@proxy.com:1337 </Alert> </> ); }; -export default ProxyForm; +export default ProxyForm; \ No newline at end of file diff --git a/src/components/organisms/RightSidePanel.tsx b/src/components/organisms/RightSidePanel.tsx index 4aaf7b214..224e0954e 100644 --- a/src/components/organisms/RightSidePanel.tsx +++ b/src/components/organisms/RightSidePanel.tsx @@ -22,6 +22,7 @@ import { emptyWorkflow } from "../../shared/constants"; import { getActiveWorkflow } from "../../api/workflow"; import DeleteIcon from '@mui/icons-material/Delete'; import ActionDescriptionBox from '../molecules/ActionDescriptionBox'; +import { useTranslation } from 'react-i18next'; const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { getActiveWorkflow(id).then( @@ -60,6 +61,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode, captureStage, setCaptureStage } = useActionContext(); const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps(); const { id, socket } = useSocketStore(); + const { t } = useTranslation(); const workflowHandler = useCallback((data: WorkflowFile) => { setWorkflow(data); @@ -139,7 +141,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture setTextLabels(prevLabels => ({ ...prevLabels, [id]: label })); } if (!label.trim()) { - setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' })); + setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') })); } else { setErrors(prevErrors => ({ ...prevErrors, [id]: '' })); } @@ -151,7 +153,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture updateBrowserTextStepLabel(id, label); setConfirmedTextSteps(prev => ({ ...prev, [id]: true })); } else { - setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' })); + setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') })); } }; @@ -213,7 +215,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture const stopCaptureAndEmitGetTextSettings = useCallback(() => { const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]); if (hasUnconfirmedTextSteps) { - notify('error', 'Please confirm all text fields'); + notify('error', t('right_panel.errors.confirm_text_fields')); return; } stopGetText(); @@ -278,7 +280,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture if (settings) { socket?.emit('action', { action: 'scrapeList', settings }); } else { - notify('error', 'Unable to create list settings. Make sure you have defined a field for the list.'); + notify('error', t('right_panel.errors.unable_create_settings')); } handleStopGetList(); onFinishCapture(); @@ -296,13 +298,13 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture case 'pagination': if (!paginationType) { - notify('error', 'Please select a pagination type.'); + notify('error', t('right_panel.errors.select_pagination')); return; } const settings = getListSettingsObject(); const paginationSelector = settings.pagination?.selector; if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) { - notify('error', 'Please select the pagination element first.'); + notify('error', t('right_panel.errors.select_pagination_element')); return; } stopPaginationMode(); @@ -314,12 +316,12 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture case 'limit': if (!limitType || (limitType === 'custom' && !customLimit)) { - notify('error', 'Please select a limit or enter a custom limit.'); + notify('error', t('right_panel.errors.select_limit')); return; } const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType); if (isNaN(limit) || limit <= 0) { - notify('error', 'Please enter a valid limit.'); + notify('error', t('right_panel.errors.invalid_limit')); return; } stopLimitMode(); @@ -348,7 +350,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture setTextLabels({}); setErrors({}); setConfirmedTextSteps({}); - notify('error', 'Capture Text Discarded'); + notify('error', t('right_panel.errors.capture_text_discarded')); }, [browserSteps, stopGetText, deleteBrowserStep]); const discardGetList = useCallback(() => { @@ -363,7 +365,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture setShowLimitOptions(false); setCaptureStage('initial'); setConfirmedListTextFields({}); - notify('error', 'Capture List Discarded'); + notify('error', t('right_panel.errors.capture_list_discarded')); }, [browserSteps, stopGetList, deleteBrowserStep, resetListState]); @@ -402,7 +404,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture </SimpleBox> */} <ActionDescriptionBox /> <Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}> - {!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" onClick={startGetList}>Capture List</Button>} + {!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" onClick={startGetList}>{t('right_panel.buttons.capture_list')}</Button>} {getList && ( <> <Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}> @@ -411,28 +413,29 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture onClick={handleConfirmListCapture} disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields} > - {captureStage === 'initial' ? 'Confirm Capture' : - captureStage === 'pagination' ? 'Confirm Pagination' : - captureStage === 'limit' ? 'Confirm Limit' : 'Finish Capture'} + {captureStage === 'initial' ? t('right_panel.buttons.confirm_capture') : + captureStage === 'pagination' ? t('right_panel.buttons.confirm_pagination') : + captureStage === 'limit' ? t('right_panel.buttons.confirm_limit') : + t('right_panel.buttons.finish_capture')} </Button> - <Button variant="outlined" color="error" onClick={discardGetList}>Discard</Button> + <Button variant="outlined" color="error" onClick={discardGetList}>{t('right_panel.buttons.discard')}</Button> </Box> </> )} {showPaginationOptions && ( <Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}> - <Typography>How can we find the next list item on the page?</Typography> - <Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>Click on next to navigate to the next page</Button> - <Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>Click on load more to load more items</Button> - <Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>Scroll down to load more items</Button> - <Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>Scroll up to load more items</Button> - <Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>No more items to load</Button> + <Typography>{t('right_panel.pagination.title')}</Typography> + <Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>{t('right_panel.pagination.click_next')}</Button> + <Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>{t('right_panel.pagination.click_load_more')}</Button> + <Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>{t('right_panel.pagination.scroll_down')}</Button> + <Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>{t('right_panel.pagination.scroll_up')}</Button> + <Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>{t('right_panel.pagination.none')}</Button> </Box> )} {showLimitOptions && ( <FormControl> <FormLabel> - <h4>What is the maximum number of rows you want to extract?</h4> + <h4>{t('right_panel.limit.title')}</h4> </FormLabel> <RadioGroup value={limitType} @@ -446,13 +449,13 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture <FormControlLabel value="10" control={<Radio />} label="10" /> <FormControlLabel value="100" control={<Radio />} label="100" /> <div style={{ display: 'flex', alignItems: 'center' }}> - <FormControlLabel value="custom" control={<Radio />} label="Custom" /> + <FormControlLabel value="custom" control={<Radio />} label={t('right_panel.limit.custom')} /> {limitType === 'custom' && ( <TextField type="number" value={customLimit} onChange={(e) => updateCustomLimit(e.target.value)} - placeholder="Enter number" + placeholder={t('right_panel.limit.enter_number')} sx={{ marginLeft: '10px', '& input': { @@ -467,21 +470,21 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture </RadioGroup> </FormControl> )} - {!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" onClick={startGetText}>Capture Text</Button>} + {!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" onClick={startGetText}>{t('right_panel.buttons.capture_text')}</Button>} {getText && <> <Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}> - <Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >Confirm</Button> - <Button variant="outlined" color="error" onClick={discardGetText} >Discard</Button> + <Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >{t('right_panel.buttons.confirm')}</Button> + <Button variant="outlined" color="error" onClick={discardGetText} >{t('right_panel.buttons.discard')}</Button> </Box> </> } - {!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" onClick={startGetScreenshot}>Capture Screenshot</Button>} + {!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>} {getScreenshot && ( <Box display="flex" flexDirection="column" gap={2}> - <Button variant="contained" onClick={() => captureScreenshot(true)}>Capture Fullpage</Button> - <Button variant="contained" onClick={() => captureScreenshot(false)}>Capture Visible Part</Button> - <Button variant="outlined" color="error" onClick={stopGetScreenshot}>Discard</Button> + <Button variant="contained" onClick={() => captureScreenshot(true)}>{t('right_panel.screenshot.capture_fullpage')}</Button> + <Button variant="contained" onClick={() => captureScreenshot(false)}>{t('right_panel.screenshot.capture_visible')}</Button> + <Button variant="outlined" color="error" onClick={stopGetScreenshot}>{t('right_panel.buttons.discard')}</Button> </Box> )} </Box> @@ -492,7 +495,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture step.type === 'text' && ( <> <TextField - label="Label" + label={t('right_panel.fields.label')} value={textLabels[step.id] || step.label || ''} onChange={(e) => handleTextLabelChange(step.id, e.target.value)} fullWidth @@ -510,7 +513,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture }} /> <TextField - label="Data" + label={t('right_panel.fields.data')} value={step.data} fullWidth margin="normal" @@ -525,8 +528,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture /> {!confirmedTextSteps[step.id] && ( <Box display="flex" justifyContent="space-between" gap={2}> - <Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>Confirm</Button> - <Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>Discard</Button> + <Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>{t('right_panel.buttons.confirm')}</Button> + <Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>{t('right_panel.buttons.discard')}</Button> </Box> )} </> @@ -535,17 +538,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture <Box display="flex" alignItems="center"> <DocumentScannerIcon sx={{ mr: 1 }} /> <Typography> - {`Take ${step.fullPage ? 'Fullpage' : 'Visible Part'} Screenshot`} + {step.fullPage ? + t('right_panel.screenshot.display_fullpage') : + t('right_panel.screenshot.display_visible')} </Typography> </Box> )} {step.type === 'list' && ( <> - <Typography>List Selected Successfully</Typography> + <Typography>{t('right_panel.messages.list_selected')}</Typography> {Object.entries(step.fields).map(([key, field]) => ( <Box key={key}> <TextField - label="Field Label" + label={t('right_panel.fields.field_label')} value={field.label || ''} onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)} fullWidth @@ -560,7 +565,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture }} /> <TextField - label="Field Data" + label={t('right_panel.fields.field_data')} value={field.data || ''} fullWidth margin="normal" @@ -580,14 +585,14 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture onClick={() => handleListTextFieldConfirm(step.id, key)} disabled={!field.label?.trim()} > - Confirm + {t('right_panel.buttons.confirm')} </Button> <Button variant="contained" color="error" onClick={() => handleListTextFieldDiscard(step.id, key)} > - Discard + {t('right_panel.buttons.discard')} </Button> </Box> )} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 000000000..c5e84364e --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,22 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: import.meta.env.DEV, + supportedLngs: ['en', 'es', 'ja', 'zh','de'], + interpolation: { + escapeValue: false, // React already escapes + }, + backend: { + loadPath: '/locales/{{lng}}.json', + }, + }); + +export default i18n; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 8c14f60ac..96f914ff7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; +import i18n from "./i18n" const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 87f90b531..3c8e08c46 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,134 +1,142 @@ -import axios from "axios"; -import { useState, useContext, useEffect, FormEvent } from "react"; -import { useNavigate, Link } from "react-router-dom"; -import { AuthContext } from "../context/auth"; -import { Box, Typography, TextField, Button, CircularProgress, Grid } from "@mui/material"; -import { useGlobalInfoStore } from "../context/globalInfo"; +import axios from "axios"; +import { useState, useContext, useEffect, FormEvent } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { AuthContext } from "../context/auth"; +import { Box, Typography, TextField, Button, CircularProgress, Grid } from "@mui/material"; +import { useGlobalInfoStore } from "../context/globalInfo"; import { apiUrl } from "../apiConfig"; +import { useTranslation } from 'react-i18next'; +import i18n from '../i18n'; const Login = () => { - const [form, setForm] = useState({ - email: "", - password: "", - }); - const [loading, setLoading] = useState(false); - const { notify } = useGlobalInfoStore(); - const { email, password } = form; + const { t } = useTranslation(); + console.log(i18n) + console.log(t) + const [form, setForm] = useState({ + email: "", + password: "", + }); + const [loading, setLoading] = useState(false); + const { notify } = useGlobalInfoStore(); + const { email, password } = form; - const { state, dispatch } = useContext(AuthContext); - const { user } = state; + const { state, dispatch } = useContext(AuthContext); + const { user } = state; - const navigate = useNavigate(); + const navigate = useNavigate(); - useEffect(() => { - if (user) { - navigate("/"); - } - }, [user, navigate]); + useEffect(() => { + if (user) { + navigate("/"); + } + }, [user, navigate]); - const handleChange = (e: any) => { - const { name, value } = e.target; - setForm({ ...form, [name]: value }); - }; + const handleChange = (e: any) => { + const { name, value } = e.target; + setForm({ ...form, [name]: value }); + }; - const submitForm = async (e: any) => { - e.preventDefault(); - setLoading(true); - try { - const { data } = await axios.post(`${apiUrl}/auth/login`, { - email, - password, - }); - dispatch({ type: "LOGIN", payload: data }); - notify("success", "Welcome to Maxun!"); - window.localStorage.setItem("user", JSON.stringify(data)); - navigate("/"); - } catch (err) { - notify("error", "Login Failed. Please try again."); - setLoading(false); - } - }; + const submitForm = async (e: any) => { + e.preventDefault(); + setLoading(true); + try { + const { data } = await axios.post(`${apiUrl}/auth/login`, { + email, + password, + }); + dispatch({ type: "LOGIN", payload: data }); + notify("success", t('login.welcome_notification')); // Translated notification + window.localStorage.setItem("user", JSON.stringify(data)); + navigate("/"); + } catch (err) { + notify("error", t('login.error_notification')); // Translated error + setLoading(false); + } + }; - return ( - <Box - sx={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - maxHeight: "100vh", - mt: 6, - padding: 4, - }} - > - - <Box - component="form" - onSubmit={submitForm} - sx={{ - textAlign: "center", - backgroundColor: "#ffffff", - padding: 6, - borderRadius: 5, - boxShadow: "0px 20px 40px rgba(0, 0, 0, 0.2), 0px -5px 10px rgba(0, 0, 0, 0.15)", - display: "flex", - flexDirection: "column", - alignItems: "center", - maxWidth: 400, - width: "100%", - }} - > - <img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} /> - <Typography variant="h4" gutterBottom> - Welcome Back! - </Typography> - <TextField - fullWidth - label="Email" - name="email" - value={email} - onChange={handleChange} - margin="normal" - variant="outlined" - required - /> - <TextField - fullWidth - label="Password" - name="password" - type="password" - value={password} - onChange={handleChange} - margin="normal" - variant="outlined" - required - /> - <Button - type="submit" - fullWidth - variant="contained" - color="primary" - sx={{ mt: 2, mb: 2 }} - disabled={loading || !email || !password} - > - {loading ? ( - <> - <CircularProgress size={20} sx={{ mr: 2 }} /> - Loading - </> - ) : ( - "Login" - )} - </Button> - <Typography variant="body2" align="center"> - Don’t have an account?{" "} - <Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}> - Register - </Link> - </Typography> - </Box> - </Box> - - ); + // Language switcher function + + + return ( + <Box + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + maxHeight: "100vh", + mt: 6, + padding: 4, + }} + > + {/* Language Switcher Buttons */} + + <Box + component="form" + onSubmit={submitForm} + sx={{ + textAlign: "center", + backgroundColor: "#ffffff", + padding: 6, + borderRadius: 5, + boxShadow: "0px 20px 40px rgba(0, 0, 0, 0.2), 0px -5px 10px rgba(0, 0, 0, 0.15)", + display: "flex", + flexDirection: "column", + alignItems: "center", + maxWidth: 400, + width: "100%", + }} + > + <img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} /> + <Typography variant="h4" gutterBottom> + {t('login.title')} + </Typography> + <TextField + fullWidth + label={t('login.email')} + name="email" + value={email} + onChange={handleChange} + margin="normal" + variant="outlined" + required + /> + <TextField + fullWidth + label={t('login.password')} + name="password" + type="password" + value={password} + onChange={handleChange} + margin="normal" + variant="outlined" + required + /> + <Button + type="submit" + fullWidth + variant="contained" + color="primary" + sx={{ mt: 2, mb: 2 }} + disabled={loading || !email || !password} + > + {loading ? ( + <> + <CircularProgress size={20} sx={{ mr: 2 }} /> + {t('login.loading')} + </> + ) : ( + t('login.button') + )} + </Button> + <Typography variant="body2" align="center"> + {t('login.register_prompt')}{" "} + <Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}> + {t('login.register_link')} + </Link> + </Typography> + </Box> + </Box> + ); }; -export default Login; +export default Login; \ No newline at end of file diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index c64de4aed..b1d2428fa 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -5,8 +5,13 @@ import { AuthContext } from "../context/auth"; import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material"; import { useGlobalInfoStore } from "../context/globalInfo"; import { apiUrl } from "../apiConfig"; +import { useTranslation } from 'react-i18next'; +import i18n from '../i18n'; + + const Register = () => { + const {t} = useTranslation(); const [form, setForm] = useState({ email: "", password: "", @@ -40,11 +45,13 @@ const Register = () => { password, }); dispatch({ type: "LOGIN", payload: data }); - notify("success", "Registration Successful!"); + notify("success", t('register.welcome_notification')); window.localStorage.setItem("user", JSON.stringify(data)); navigate("/"); } catch (error:any) { - notify("error", `Registration Failed. Please try again. ${error.response.data}`); + + notify("error", error.response.data || t('register.error_notification')); + setLoading(false); } }; @@ -78,11 +85,11 @@ const Register = () => { > <img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} /> <Typography variant="h4" gutterBottom> - Create an Account + {t('register.title')} </Typography> <TextField fullWidth - label="Email" + label={t('register.email')} name="email" value={email} onChange={handleChange} @@ -92,7 +99,7 @@ const Register = () => { /> <TextField fullWidth - label="Password" + label={t('register.password')} name="password" type="password" value={password} @@ -115,13 +122,14 @@ const Register = () => { Loading </> ) : ( - "Register" + t('register.button') )} </Button> <Typography variant="body2" align="center"> - Already have an account?{" "} + {t('register.register_prompt')}{" "} <Link to="/login" style={{ textDecoration: "none", color: "#ff33cc" }}> - Login + + {t('register.login_link')} </Link> </Typography> </Box>