Compare commits

...

20 Commits

Author SHA1 Message Date
Marc Brooks 13407d2bd8
Merge 1647b80b8c into cc9ff74276 2025-10-14 07:31:02 +00:00
Marc Brooks 1647b80b8c
Settings macros pages 2025-10-14 02:30:41 -05:00
Marc Brooks 214bd69d10
Settings keyboard page 2025-10-14 01:28:23 -05:00
Marc Brooks e39fdd9c7d
Settings hardware page 2025-10-14 00:48:07 -05:00
Marc Brooks 340a04f23a
Settings General pages 2025-10-14 00:03:18 -05:00
Marc Brooks f2e665126a
Settings appearance page 2025-10-13 20:36:48 -05:00
Marc Brooks 0dcf56ef18
Fix UI lint warnings
There were a bunch of ref and useEffect violations.
2025-10-13 20:12:33 -05:00
Marc Brooks 0eb577b6f7
Settings Advanced page 2025-10-13 18:08:33 -05:00
Marc Brooks 5613555b39
Fix ref lint warning 2025-10-13 18:08:32 -05:00
Marc Brooks a40c27269a
Settings local auth page 2025-10-13 18:08:32 -05:00
Marc Brooks c6cb2e9cb6
Settings Access page 2025-10-13 16:10:17 -05:00
Marc Brooks 567a6d5cbc
Bump packages 2025-10-13 16:06:33 -05:00
Marc Brooks 774615557c PR updates 2025-10-11 00:51:03 +00:00
Marc Brooks 66ab743dfe
Localize all pages except Settings 2025-10-08 22:02:06 -05:00
Marc Brooks ea43caae27
Localized all components, hooks, providers, hooks 2025-10-08 16:29:56 -05:00
Marc Brooks 2037c9d478
File formatting pass 2025-10-08 16:28:16 -05:00
Marc Brooks b2d657beaa Update Chinese translations
Accidentally lost the changes that @ym provided, brought them back
2025-10-08 18:22:25 +00:00
Marc Brooks 985b53c02b
Popovers and sidebar 2025-10-07 18:45:10 -05:00
Marc Brooks 474cb70e80
PR feedback 2025-10-07 17:59:07 -05:00
Marc Brooks 7f3f8694b9
Add inlang/paraglide-js localization
Localize the extension popovers.
Update package and fix tsconfig.json
Expand development directory guide
Move messages under localization
2025-10-07 12:27:39 -05:00
121 changed files with 4038 additions and 1916 deletions

View File

@ -8,7 +8,8 @@
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"onCreateCommand": ".devcontainer/install-deps.sh",
"customizations": {
@ -31,7 +32,10 @@
// Frontend
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
"codeandstuff.package-json-upgrade",
// Localization
"inlang.vs-code-extension"
]
}
}

25
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"recommendations": [
// coding styles
"chrislajoie.vscode-modelines",
"editorconfig.editorconfig",
// GitHub
"GitHub.vscode-pull-request-github",
"github.vscode-github-actions",
// Golang
"golang.go",
// C / C++
"ms-vscode.cpptools",
"ms-vscode.cpptools-extension-pack",
// CMake / Makefile
"ms-vscode.makefile-tools",
"ms-vscode.cmake-tools",
// Frontend
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"codeandstuff.package-json-upgrade",
// Localization
"inlang.vs-code-extension"
]
}

View File

@ -121,12 +121,16 @@ tail -f /var/log/jetkvm.log
├── scripts/ # Bash shell scripts for building and deploying
└── static/ # (react client build output)
└── ui/ # React frontend
├── localization/ # Client UI localization (i18n)
│ ├── jetKVM.UI.inlang/ # Settings for inlang
│ └── messages/ # Messages localized
├── public/ # UI website static images and fonts
└── src/ # Client React UI
├── assets/ # UI in-page images
├── components/ # UI components
├── hooks/ # Hooks (stores, RPC handling, virtual devices)
├── keyboardLayouts/ # Keyboard layout definitions
│ ├── paraglide/ # (localization compiled messages output)
├── providers/ # Feature flags
└── routes/ # Pages (login, settings, etc.)
```

View File

@ -9,8 +9,6 @@ const {
fixupConfigRules,
} = require("@eslint/compat");
const tsParser = require("@typescript-eslint/parser");
const reactRefresh = require("eslint-plugin-react-refresh");
const js = require("@eslint/js");
const {
@ -23,6 +21,9 @@ const compat = new FlatCompat({
allConfig: js.configs.all
});
const tsParser = require("@typescript-eslint/parser");
const reactRefresh = require("eslint-plugin-react-refresh");
module.exports = defineConfig([{
languageOptions: {
globals: {
@ -81,7 +82,10 @@ module.exports = defineConfig([{
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@hooks", "./src/hooks"],
["@providers", "./src/providers"],
["@assets", "./src/assets"],
["@localizations", "./localization/paraglide"],
["@", "./src"],
],

View File

@ -45,31 +45,39 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="JetKVM" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/public/site.webmanifest" />
<meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." />
<meta
name="description"
content="A web-based KVM console for managing remote servers."
/>
<script>
function applyThemeFromPreference() {
// dark theme setup
var darkDesired = localStorage.theme === "dark" ||
var darkDesired =
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", darkDesired)
document.documentElement.classList.toggle("dark", darkDesired);
}
// initial theme application
applyThemeFromPreference();
// Listen for system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", applyThemeFromPreference);
window
.matchMedia("(prefers-color-scheme: light)")
.addEventListener("change", applyThemeFromPreference);
</script>
</head>
<body
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased dark:bg-slate-900 md:text-base"
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased md:text-base dark:bg-slate-900"
>
<div id="root" class="w-full h-full"></div>
<div id="root" class="h-full w-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1 @@
cache

View File

@ -0,0 +1 @@
TI1a2RjjH4qkImNj0w

View File

@ -0,0 +1,40 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"sourceLanguageTag": "en",
"locales": [
"en",
"da",
"de",
"es",
"fr",
"it",
"nb",
"sv",
"zh"
],
"languageTags": [
"en",
"da",
"de",
"es",
"fr",
"it",
"nb",
"sv",
"zh"
],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"strategy": [
"cookie",
"localStorage",
"preferredLanguage",
"baseLocale"
]
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "Åh nej!",
"something_went_wrong": "Noget gik galt. Prøv igen senere, eller kontakt support.",
"jetkvm_logo": "JetKVM-logo",
"load": "Indlæs",
"unknown_error": "Ukendt fejl",
"action_bar_virtual_media": "Virtuelle medier",
"action_bar_paste_text": "Indsæt tekst",
"action_bar_web_terminal": "Webterminal",
"action_bar_wake_on_lan": "Vågn på LAN",
"action_bar_virtual_keyboard": "Virtuelt tastatur",
"action_bar_extension": "Udvidelse",
"action_bar_connection_stats": "Forbindelsesstatistik",
"action_bar_settings": "Indstillinger",
"action_bar_fullscreen": "Fuldskærm",
"action_bar_exit_fullscreen": "Afslut fuldskærm",
"extensions_popover_extensions": "Udvidelser",
"extension_popover_set_error_notification": "Kunne ikke angive aktiv udvidelse: {error}",
"extension_popover_unload_extension": "Fjern udvidelse",
"extension_popover_load_and_manage_extensions": "Indlæs og administrer dine udvidelser",
"extensions_atx_power_control": "ATX-strømstyring",
"extensions_atx_power_control_description": "Styr din maskines strømtilstand via ATX-strømstyring.",
"extensions_dc_power_control": "DC-strømstyring",
"extensions_dc_power_control_description": "Styr din DC-strømforlænger",
"extension_serial_console": "Seriel konsol",
"extension_serial_console_description": "Få adgang til din serielle konsoludvidelse",
"atx_power_control_get_state_error": "Kunne ikke hente ATX-strømtilstand: {error}",
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømfunktion {action} : {error}",
"atx_power_control_power_button": "Magt",
"atx_power_control_short_power_button": "Kort tryk",
"atx_power_control_long_power_button": "Langt tryk",
"atx_power_control_reset_button": "Nulstil",
"atx_power_control_power_led": "Strøm-LED",
"atx_power_control_hdd_led": "HDD-LED",
"dc_power_control_get_state_error": "Kunne ikke hente DC-strømtilstand: {error}",
"dc_power_control_set_power_state_error": "Kunne ikke sende DC-strømstatus til {enabled} : {error}",
"dc_power_control_set_restore_state_error": "Kunne ikke sende DC-strømgendannelsesstatus til {state} : {error}",
"dc_power_control_power_on_button": "Tænd",
"dc_power_control_power_off_button": "Sluk",
"dc_power_control_restore_power_state": "Gendan strømtab",
"dc_power_control_power_on_state": "Tænd",
"dc_power_control_power_off_state": "Sluk",
"dc_power_control_voltage": "Spænding",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Strøm",
"dc_power_control_current_unit": "A",
"dc_power_control_power": "Magt",
"dc_power_control_power_unit": "W",
"serial_console_get_state_error": "Kunne ikke hente indstillinger for seriel konsol: {error}",
"serial_console_set_power_state_error": "Kunne ikke indstille seriel konsolindstillinger til {settings} : {error}",
"serial_console_configure_description": "Konfigurer dine serielle konsolindstillinger",
"serial_console_open_console": "Åbn konsol",
"serial_console_baud_rate": "Baudhastighed",
"serial_console_data_bits": "Databits",
"serial_console_stop_bits": "Stopbits",
"serial_console_parity": "Paritet",
"serial_console_parity_even": "Lige paritet",
"serial_console_parity_odd": "Ulige paritet",
"serial_console_parity_none": "Ingen paritet",
"serial_console_parity_mark": "Mark Paritet",
"serial_console_parity_space": "Rumparitet",
"serial_console_get_settings_error": "Kunne ikke hente indstillinger for seriel konsol: {error}",
"serial_console_set_settings_error": "Kunne ikke indstille seriel konsolindstillinger til {settings} : {error}"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "Oh nein!",
"something_went_wrong": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es später noch einmal oder wenden Sie sich an den Support.",
"jetkvm_logo": "JetKVM Logo",
"load": "Laden",
"unknown_error": "Unbekannter Fehler",
"action_bar_virtual_media": "Virtuelle Medien",
"action_bar_paste_text": "Text einfügen",
"action_bar_web_terminal": "Web-Terminal",
"action_bar_wake_on_lan": "Wake-on-LAN",
"action_bar_virtual_keyboard": "Virtuelle Tastatur",
"action_bar_extension": "Verlängerung",
"action_bar_connection_stats": "Verbindungsstatistiken",
"action_bar_settings": "Einstellungen",
"action_bar_fullscreen": "Vollbild",
"action_bar_exit_fullscreen": "Vollbildmodus beenden",
"extensions_popover_extensions": "Erweiterungen",
"extension_popover_set_error_notification": "Fehler beim Festlegen der aktiven Erweiterung: {error}",
"extension_popover_unload_extension": "Erweiterung entladen",
"extension_popover_load_and_manage_extensions": "Laden und verwalten Sie Ihre Erweiterungen",
"extensions_atx_power_control": "ATX-Stromsteuerung",
"extensions_atx_power_control_description": "Steuern Sie den Energiezustand Ihrer Maschine über die ATX-Energiesteuerung.",
"extensions_dc_power_control": "Gleichstromsteuerung",
"extensions_dc_power_control_description": "Steuern Sie Ihre DC-Stromerweiterung",
"extension_serial_console": "Serielle Konsole",
"extension_serial_console_description": "Greifen Sie auf Ihre serielle Konsolenerweiterung zu",
"atx_power_control_get_state_error": "ATX-Stromversorgungsstatus konnte nicht abgerufen werden: {error}",
"atx_power_control_send_action_error": "ATX-Stromversorgungsaktion {action} konnte nicht gesendet werden: {error}",
"atx_power_control_power_button": "Leistung",
"atx_power_control_short_power_button": "Kurzes Drücken",
"atx_power_control_long_power_button": "Langes Drücken",
"atx_power_control_reset_button": "Zurücksetzen",
"atx_power_control_power_led": "Betriebs-LED",
"atx_power_control_hdd_led": "Festplatten-LED",
"dc_power_control_get_state_error": "Der Gleichstromstatus konnte nicht abgerufen werden: {error}",
"dc_power_control_set_power_state_error": "Der DC-Stromversorgungsstatus konnte nicht an {enabled} werden: {error}",
"dc_power_control_set_restore_state_error": "Der Status zur Wiederherstellung der Gleichstromversorgung konnte nicht an {state} gesendet werden: {error}",
"dc_power_control_power_on_button": "Einschalten",
"dc_power_control_power_off_button": "Ausschalten",
"dc_power_control_restore_power_state": "Wiederherstellung nach Stromausfall",
"dc_power_control_power_on_state": "Einschalten",
"dc_power_control_power_off_state": "Ausschalten",
"dc_power_control_voltage": "Stromspannung",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Aktuell",
"dc_power_control_current_unit": "A",
"dc_power_control_power": "Leistung",
"dc_power_control_power_unit": "W",
"serial_console_get_state_error": "Die seriellen Konsoleneinstellungen konnten nicht abgerufen werden: {error}",
"serial_console_set_power_state_error": "Die Einstellungen der seriellen Konsole konnten nicht auf {settings} festgelegt werden: {error}",
"serial_console_configure_description": "Konfigurieren Sie die Einstellungen Ihrer seriellen Konsole",
"serial_console_open_console": "Konsole öffnen",
"serial_console_baud_rate": "Baudrate",
"serial_console_data_bits": "Datenbits",
"serial_console_stop_bits": "Stoppbits",
"serial_console_parity": "Parität",
"serial_console_parity_even": "Gerade Parität",
"serial_console_parity_odd": "Ungerade Parität",
"serial_console_parity_none": "Keine Parität",
"serial_console_parity_mark": "Parität markieren",
"serial_console_parity_space": "Raumparität",
"serial_console_get_settings_error": "Die seriellen Konsoleneinstellungen konnten nicht abgerufen werden: {error}",
"serial_console_set_settings_error": "Die Einstellungen der seriellen Konsole konnten nicht auf {settings} festgelegt werden: {error}"
}

View File

@ -0,0 +1,726 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"access_adopt_kvm": "Adopt KVM to Cloud",
"access_adopted_message": "Your device is adopted to the Cloud",
"access_auth_mode_no_password": "Current mode: No password",
"access_auth_mode_password": "Current mode: Password protected",
"access_authentication_mode_title": "Authentication Mode",
"access_certificate_label": "Certificate",
"access_change_password_button": "Change Password",
"access_change_password_description": "Update your device access password",
"access_change_password_title": "Change Password",
"access_cloud_api_url_label": "Cloud API URL",
"access_cloud_app_url_label": "Cloud Application URL",
"access_cloud_provider_description": "Select the cloud provider for your device",
"access_cloud_provider_title": "Cloud Provider",
"access_cloud_security_title": "Cloud Security",
"access_confirm_deregister": "Are you sure you want to de-register this device?",
"access_deregister": "De-register from Cloud",
"access_description": "Manage the Access Control of the device",
"access_disable_protection": "Disable Protection",
"access_enable_password": "Enable Password",
"access_failed_deregister": "Failed to de-register device: {error}",
"access_failed_update_cloud_url": "Failed to update cloud URL: {error}",
"access_failed_update_tls": "Failed to update TLS settings: {error}",
"access_github_link": "GitHub",
"access_https_description": "Configure secure HTTPS access to your device",
"access_https_mode_title": "HTTPS Mode",
"access_learn_security": "Learn about our cloud security",
"access_local_description": "Manage the mode of local access to the device",
"access_local_title": "Local",
"access_no_device_id": "No device ID available",
"access_private_key_description": "For security reasons, it will not be displayed after saving.",
"access_private_key_label": "Private Key",
"access_provider_custom": "Custom",
"access_provider_jetkvm": "JetKVM Cloud",
"access_remote_description": "Manage the mode of Remote access to the device",
"access_security_encryption": "End-to-end encryption using WebRTC (DTLS and SRTP)",
"access_security_oidc": "OIDC (OpenID Connect) authentication",
"access_security_open_source": "All cloud components are open-source and available on GitHub.",
"access_security_streams": "All streams encrypted in transit",
"access_security_zero_trust": "Zero Trust security model",
"access_title": "Access",
"access_tls_certificate_description": "Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates).",
"access_tls_certificate_title": "TLS Certificate",
"access_tls_custom": "Custom",
"access_tls_disabled": "Disabled",
"access_tls_self_signed": "Self-signed",
"access_tls_updated": "TLS settings updated successfully",
"access_update_tls_settings": "Update TLS Settings",
"action_bar_connection_stats": "Connection Stats",
"action_bar_exit_fullscreen": "Exit Fullscreen",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen",
"action_bar_paste_text": "Paste text",
"action_bar_settings": "Settings",
"action_bar_virtual_keyboard": "Virtual Keyboard",
"action_bar_virtual_media": "Virtual Media",
"action_bar_wake_on_lan": "Wake on LAN",
"action_bar_web_terminal": "Web Terminal",
"advanced_description": "Access additional settings for troubleshooting and customization",
"advanced_dev_channel_description": "Receive early updates from the development channel",
"advanced_dev_channel_title": "Dev Channel Updates",
"advanced_developer_mode_description": "Enable advanced features for developers",
"advanced_developer_mode_enabled_title": "Developer Mode Enabled",
"advanced_developer_mode_title": "Developer Mode",
"advanced_developer_mode_warning_advanced": "For advanced users only. Not for production use.",
"advanced_developer_mode_warning_risks": "Only use if you understand the risks",
"advanced_developer_mode_warning_security": "Security is weakened while active",
"advanced_disable_usb_emulation": "Disable USB Emulation",
"advanced_enable_usb_emulation": "Enable USB Emulation",
"advanced_error_loopback_disable": "Failed to disable loopback-only mode: {error}",
"advanced_error_loopback_enable": "Failed to enable loopback-only mode: {error}",
"advanced_error_reset_config": "Failed to reset configuration: {error}",
"advanced_error_set_dev_channel": "Failed to set dev channel state: {error}",
"advanced_error_set_dev_mode": "Failed to set dev mode: {error}",
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
"advanced_loopback_only_title": "Loopback-Only Mode",
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
"advanced_loopback_warning_cloud": "Cloud access enabled and working",
"advanced_loopback_warning_confirm": "I Understand, Enable Anyway",
"advanced_loopback_warning_description": "WARNING: This will restrict web interface access to localhost (127.0.0.1) only.",
"advanced_loopback_warning_ssh": "SSH access configured and tested",
"advanced_loopback_warning_title": "Enable Loopback-Only Mode?",
"advanced_reset_config_button": "Reset Config",
"advanced_reset_config_description": "Reset configuration to default. This will log you out.",
"advanced_reset_config_title": "Reset Configuration",
"advanced_ssh_access_description": "Add your SSH public key to enable secure remote access to the device",
"advanced_ssh_access_title": "SSH Access",
"advanced_ssh_default_user": "The default SSH user is",
"advanced_ssh_public_key_label": "SSH Public Key",
"advanced_ssh_public_key_placeholder": "Enter your SSH public key",
"advanced_success_loopback_disabled": "Loopback-only mode disabled. Restart your device to apply.",
"advanced_success_loopback_enabled": "Loopback-only mode enabled. Restart your device to apply.",
"advanced_success_reset_config": "Configuration reset to default successfully",
"advanced_success_update_ssh_key": "SSH key updated successfully",
"advanced_title": "Advanced",
"advanced_troubleshooting_mode_description": "Diagnostic tools and additional controls for troubleshooting and development purposes",
"advanced_troubleshooting_mode_title": "Troubleshooting Mode",
"advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation",
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard",
"already_adopted_title": "Device Already Registered",
"appearance_description": "Choose your preferred color theme",
"appearance_page_description": "Customize the look and feel of your JetKVM interface",
"appearance_theme_dark": "Dark",
"appearance_theme_light": "Light",
"appearance_theme_system": "System",
"appearance_theme": "Theme",
"appearance_title": "Appearance",
"attach": "Attach",
"atx_power_control_get_state_error": "Failed to get ATX power state: {error}",
"atx_power_control_hdd_led": "HDD LED",
"atx_power_control_long_power_button": "Long Press",
"atx_power_control_power_button": "Power",
"atx_power_control_power_led": "Power LED",
"atx_power_control_reset_button": "Reset",
"atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}",
"atx_power_control_short_power_button": "Short Press",
"auth_authentication_mode_error": "An error occurred while setting the authentication mode",
"auth_authentication_mode_invalid": "Invalid authentication mode",
"auth_authentication_mode": "Please select an authentication mode",
"auth_connect_to_cloud_action": "Log in & Connect device",
"auth_connect_to_cloud_description": "Unlock remote access and advanced features for your device",
"auth_connect_to_cloud": "Connect your JetKVM to the cloud",
"auth_header_cta_already_have_account": "Already have an account?",
"auth_header_cta_dont_have_account": "Don't have an account?",
"auth_header_cta_new_to_jetkvm": "New to JetKVM?",
"auth_login_action": "Log in",
"auth_login_description": "Log in to access and manage your devices securely",
"auth_login": "Log in to your JetKVM account",
"auth_mode_local_change_later": "You can always change your authentication method later in the settings.",
"auth_mode_local_description": "Select how you would like to secure your JetKVM device locally.",
"auth_mode_local_no_password_description": "Quick access without password authentication.",
"auth_mode_local_no_password": "No Password",
"auth_mode_local_password_confirm_description": "Confirm your password",
"auth_mode_local_password_confirm_label": "Confirm Password",
"auth_mode_local_password_description": "Secure your device with a password for added protection.",
"auth_mode_local_password_do_not_match": "Passwords do not match",
"auth_mode_local_password_failed_set": "Failed to set password: {error}",
"auth_mode_local_password_note_local": "All data remains on your local device.",
"auth_mode_local_password_note": "This password will be used to secure your device data and protect against unauthorized access.",
"auth_mode_local_password_set_button": "Set Password",
"auth_mode_local_password_set_description": "Create a strong password to secure your JetKVM device locally.",
"auth_mode_local_password_set_label": "Enter a password",
"auth_mode_local_password_set": "Set a Password",
"auth_mode_local_password": "Password",
"auth_mode_local": "Local Authentication Method",
"auth_signup_connect_to_cloud_action": "Signup & Connect device",
"auth_signup_create_account_action": "Create Account",
"auth_signup_create_account_description": "Create your account and start managing your devices with ease.",
"auth_signup_create_account": "Create your JetKVM account",
"back_to_devices": "Back to Devices",
"back": "Back",
"cancel": "Cancel",
"close": "Close",
"cloud_kvms_description": "Manage your cloud KVMs and connect to them securely.",
"cloud_kvms_no_devices_description": "You don't have any devices with enabled JetKVM Cloud yet.",
"cloud_kvms_no_devices": "No devices found",
"cloud_kvms": "Cloud KVMs",
"confirm": "Confirm",
"connect_to_kvm": "Connect to KVM",
"connecting_to_device": "Connecting to device…",
"connection_established": "Connection established",
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter Buffer Avg. Delay",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_connection_description": "The connection between the client and the JetKVM.",
"connection_stats_connection": "Connection",
"connection_stats_frames_per_second_description": "Number of inbound video frames displayed per second.",
"connection_stats_frames_per_second": "Frames per second",
"connection_stats_network_stability_description": "How steady the flow of inbound video packets is across the network.",
"connection_stats_network_stability": "Network Stability",
"connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.",
"connection_stats_packets_lost": "Packets Lost",
"connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.",
"connection_stats_playback_delay": "Playback Delay",
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
"connection_stats_round_trip_time": "Round-Trip Time",
"connection_stats_sidebar": "Connection Stats",
"connection_stats_video_description": "The video stream from the JetKVM to the client.",
"connection_stats_video": "Video",
"continue": "Continue",
"creating_peer_connection": "Creating peer connection…",
"dc_power_control_current_unit": "A",
"dc_power_control_current": "Current",
"dc_power_control_get_state_error": "Failed to get DC power state: {error}",
"dc_power_control_power_off_button": "Power Off",
"dc_power_control_power_off_state": "Power OFF",
"dc_power_control_power_on_button": "Power On",
"dc_power_control_power_on_state": "Power ON",
"dc_power_control_power_unit": "W",
"dc_power_control_power": "Power",
"dc_power_control_restore_last_state": "Last State",
"dc_power_control_restore_power_state": "Restore Power Loss",
"dc_power_control_set_power_state_error": "Failed to send DC power state to {enabled}: {error}",
"dc_power_control_set_restore_state_error": "Failed to send DC power restore state to {state}: {error}",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Voltage",
"default": "Default",
"delete": "Delete",
"deregister_button": "Deregister from Cloud",
"deregister_cloud_devices": "Cloud Devices",
"deregister_description": "This will remove the device from your cloud account and revoke remote access to it. Please note that local access will still be possible",
"deregister_error": "There was an error {status} deregistering your device. Please try again.",
"deregister_from_cloud": "Deregister from cloud",
"deregister_headline": "Deregister {device} from your cloud account",
"detach": "Detach",
"dhcp_lease_boot_file": "Boot File",
"dhcp_lease_boot_next_server": "Boot Next Server",
"dhcp_lease_boot_server_name": "Boot Server Name",
"dhcp_lease_broadcast": "Broadcast",
"dhcp_lease_domain": "Domain",
"dhcp_lease_gateway": "Gateway",
"dhcp_lease_header": "DHCP Lease Information",
"dhcp_lease_hostname": "Hostname",
"dhcp_lease_lease_expires": "Lease Expires",
"dhcp_lease_maximum_transfer_unit": "MTU",
"dhcp_lease_renew": "Renew DHCP Lease",
"dhcp_lease_time_to_live": "TTL",
"dhcp_server": "DHCP Server",
"dns_servers": "DNS Servers",
"establishing_secure_connection": "Establishing secure connection…",
"experimental": "Experimental",
"extension_popover_load_and_manage_extensions": "Load and manage your extensions",
"extension_popover_set_error_notification": "Failed to set active extension: {error}",
"extension_popover_unload_extension": "Unload Extension",
"extension_serial_console_description": "Access your serial console extension",
"extension_serial_console": "Serial Console",
"extensions_atx_power_control_description": "Control the power state of your machine via ATX power control.",
"extensions_atx_power_control": "ATX Power Control",
"extensions_dc_power_control_description": "Control your DC Power extension",
"extensions_dc_power_control": "DC Power Control",
"extensions_popover_extensions": "Extensions",
"gathering_ice_candidates": "Gathering ICE candidates…",
"general_app_version": "App: {version}",
"general_auto_update_description": "Automatically update the device to the latest version",
"general_auto_update_error": "Failed to set auto-update: {error}",
"general_auto_update_title": "Auto Update",
"general_check_for_updates": "Check for Updates",
"general_page_description": "Configure device settings and update preferences",
"general_reboot_description": "Do you want to proceed with rebooting the system?",
"general_reboot_device_description": "Power cycle the JetKVM",
"general_reboot_device": "Reboot Device",
"general_reboot_no_button": "No",
"general_reboot_title": "Reboot JetKVM",
"general_reboot_yes_button": "Yes",
"general_system_version": "System: {version}",
"general_title": "General",
"general_update_app_update_title": "App Update",
"general_update_application_type": "App",
"general_update_available_description": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
"general_update_available_title": "Update available",
"general_update_background_button": "Update in Background",
"general_update_check_again_button": "Check Again",
"general_update_checking_description": "We're ensuring your device has the latest features and improvements.",
"general_update_checking_title": "Checking for updates…",
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
"general_update_completed_title": "Update Completed Successfully",
"general_update_error_description": "An error occurred while updating your device. Please try again later.",
"general_update_error_details": "Error details: {errorMessage}",
"general_update_error_title": "Update Error",
"general_update_later_button": "Do it later",
"general_update_now_button": "Update Now",
"general_update_rebooting": "Rebooting to complete the update…",
"general_update_status_awaiting_reboot": "Awaiting reboot",
"general_update_status_downloading": "Downloading {update_type} update…",
"general_update_status_fetching": "Fetching update information…",
"general_update_status_installing": "Installing {update_type} update…",
"general_update_status_verifying": "Verifying {update_type} update…",
"general_update_system_type": "System",
"general_update_system_update_title": "Linux System Update",
"general_update_up_to_date_description": "Your system is running the latest version. No updates are currently available.",
"general_update_up_to_date_title": "System is up to date",
"general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.",
"general_update_updating_title": "Updating your device",
"getting_remote_session_description": "Getting remote session description attempt {attempt}",
"hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
"hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",
"hardware_backlight_settings_success": "Backlight settings updated successfully",
"hardware_dim_display_after_description": "Set how long to wait before dimming the display",
"hardware_dim_display_after_title": "Dim Display After",
"hardware_display_brightness_description": "Set the brightness of the display",
"hardware_display_brightness_high": "High",
"hardware_display_brightness_low": "Low",
"hardware_display_brightness_medium": "Medium",
"hardware_display_brightness_off": "Off",
"hardware_display_brightness_title": "Display Brightness",
"hardware_display_orientation_description": "Set the orientation of the display",
"hardware_display_orientation_error": "Failed to set display orientation: {error}",
"hardware_display_orientation_inverted": "Inverted",
"hardware_display_orientation_normal": "Normal",
"hardware_display_orientation_success": "Display orientation updated successfully",
"hardware_display_orientation_title": "Display Orientation",
"hardware_display_wake_up_note": "The display will wake up when the connection state changes, or when touched.",
"hardware_page_description": "Configure display settings and hardware options for your JetKVM device",
"hardware_time_1_hour": "1 Hour",
"hardware_time_1_minute": "1 Minute",
"hardware_time_10_minutes": "10 Minutes",
"hardware_time_30_minutes": "30 Minutes",
"hardware_time_5_minutes": "5 Minutes",
"hardware_time_never": "Never",
"hardware_title": "Hardware",
"hardware_turn_off_display_after_description": "Period of inactivity before display automatically turns off",
"hardware_turn_off_display_after_title": "Turn off Display After",
"hide": "Hide",
"ice_gathering_completed": "ICE Gathering completed",
"info_caps_lock": "Caps Lock",
"info_compose": "Compose",
"info_hdmi_state": "HDMI State:",
"info_hidrpc_state": "HidRPC State:",
"info_kana": "Kana",
"info_keys": "Keys:",
"info_last_move": "Last Move:",
"info_num_lock": "Num Lock",
"info_paste_enabled": "Enabled",
"info_paste_mode": "Paste Mode:",
"info_pointer": "Pointer:",
"info_relayed_by_cloudflare": "Relayed by Cloudflare",
"info_resolution": "Resolution:",
"info_scroll_lock": "Scroll Lock",
"info_shift": "Shift",
"info_usb_state": "USB State:",
"info_video_size": "Video Size:",
"input_disabled": "Input disabled",
"invalid_password": "Invalid password",
"ip_address": "IP Address",
"ipv6_address_label": "Address",
"ipv6_addresses": "IPv6 Addresses",
"ipv6_information": "IPv6 Information",
"ipv6_link_local": "Link-local",
"ipv6_preferred_lifetime": "Preferred Lifetime",
"ipv6_valid_lifetime": "Valid Lifetime",
"jetkvm_description": "JetKVM combines powerful hardware with intuitive software to provide a seamless remote control experience.",
"jetkvm_device": "JetKVM Device",
"jetkvm_logo": "JetKVM Logo",
"jetkvm_setup": "Set up your JetKVM",
"jetkvm": "JetKVM",
"jiggler_cron_schedule_description": "Cron expression for scheduling",
"jiggler_cron_schedule_label": "Cron Schedule",
"jiggler_example_business_hours_early": "Business Hours 8-17",
"jiggler_example_business_hours_late": "Business Hours 9-17",
"jiggler_examples_label": "Examples",
"jiggler_inactivity_limit_description": "Inactivity time before jiggle",
"jiggler_inactivity_limit_label": "Inactivity Limit Seconds",
"jiggler_more_examples": "More examples",
"jiggler_random_delay_description": "To avoid recognizable patterns",
"jiggler_random_delay_label": "Random delay",
"jiggler_save_jiggler_config": "Save Jiggler Config",
"jiggler_timezone_description": "Timezone for cron schedule",
"jiggler_timezone_label": "Timezone",
"keyboard_description": "Configure keyboard settings for your device",
"keyboard_layout_description": "Keyboard layout of target operating system",
"keyboard_layout_error": "Failed to set keyboard layout: {error}",
"keyboard_layout_long_description": "The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.",
"keyboard_layout_success": "Keyboard layout set successfully to {layout}",
"keyboard_layout_title": "Keyboard Layout",
"keyboard_show_pressed_keys_description": "Display currently pressed keys in the status bar",
"keyboard_show_pressed_keys_title": "Show Pressed Keys",
"keyboard_title": "Keyboard",
"kvm_terminal": "KVM Terminal",
"last_online": "Last online {time}",
"learn_more": "Learn more",
"load": "Load",
"loading": "Loading…",
"local_auth_change_local_device_password_description": "Enter your current password and a new password to update your local device protection.",
"local_auth_change_local_device_password_title": "Change Local Device Password",
"local_auth_confirm_new_password_label": "Confirm New Password",
"local_auth_create_confirm_password_label": "Confirm New Password",
"local_auth_create_confirm_password_placeholder": "Re-enter your password",
"local_auth_create_description": "Create a password to protect your device from unauthorized local access.",
"local_auth_create_new_password_label": "New Password",
"local_auth_create_new_password_placeholder": "Enter a strong password",
"local_auth_create_not_now_button": "Not Now",
"local_auth_create_secure_button": "Secure Device",
"local_auth_create_title": "Local Device Protection",
"local_auth_current_password_label": "Current Password",
"local_auth_disable_local_device_protection_description": "Enter your current password to disable local device protection.",
"local_auth_disable_local_device_protection_title": "Disable Local Device Protection",
"local_auth_disable_protection_button": "Disable Protection",
"local_auth_enter_current_password_placeholder": "Enter your current password",
"local_auth_enter_new_password_placeholder": "Enter a new strong password",
"local_auth_error_changing_password": "An error occurred while changing the password",
"local_auth_error_disabling_password": "An error occurred while disabling the password",
"local_auth_error_enter_current_password": "Please enter your current password",
"local_auth_error_enter_new_password": "Please enter a new password",
"local_auth_error_enter_old_password": "Please enter your old password",
"local_auth_error_enter_password": "Please enter a password",
"local_auth_error_passwords_not_match": "Passwords do not match",
"local_auth_error_setting_password": "An error occurred while setting the password",
"local_auth_new_password_label": "New Password",
"local_auth_reenter_new_password_placeholder": "Re-enter your new password",
"local_auth_success_password_disabled_description": "You've successfully disabled the password protection for local access. Remember, your device is now less secure.",
"local_auth_success_password_disabled_title": "Password Protection Disabled",
"local_auth_success_password_set_description": "You've successfully set up local device protection. Your device is now secure against unauthorized local access.",
"local_auth_success_password_set_title": "Password Set Successfully",
"local_auth_success_password_updated_description": "You've successfully changed your local device protection password. Make sure to remember your new password for future access.",
"local_auth_success_password_updated_title": "Password Updated Successfully",
"local_auth_update_confirm_password_label": "Confirm New Password",
"local_auth_update_current_password_label": "Current Password",
"local_auth_update_description": "Enter your current password and a new password to update your local device protection.",
"local_auth_update_new_password_label": "New Password",
"local_auth_update_password_button": "Update Password",
"local_auth_update_title": "Change Local Device Password",
"log_in": "Log In",
"log_out": "Log out",
"logged_in_as": "Logged in as",
"login_enter_password_description": "Enter your password to access your JetKVM.",
"login_enter_password": "Enter your password",
"login_error": "An error occurred while logging in",
"login_forgot_password": "Forgot password?",
"login_password_label": "Password",
"login_welcome_back": "Welcome back to JetKVM",
"macro_add_step": "Add Step{maxed_out}",
"macro_at_least_one_step_keys_or_modifiers": "At least one step must have keys or modifiers",
"macro_at_least_one_step_required": "At least one step is required",
"macro_max_steps_error": "You can only add a maximum of {max} steps per macro.",
"macro_max_steps_reached": "({max} max)",
"macro_name_label": "Macro Name",
"macro_name_required": "Name is required",
"macro_name_too_long": "Name must be less than 50 characters",
"macro_please_fix_validation_errors": "Please fix the validation errors",
"macro_save_error": "An error occurred while saving.",
"macro_save": "Save Macro",
"macro_step_count": "{steps} / {max} steps",
"macro_step_duration_description": "Time to wait before executing the next step.",
"macro_step_duration_label": "Step Duration",
"macro_step_keys_description": "Maximum {max} keys per step.",
"macro_step_keys_label": "Keys",
"macro_step_max_keys_reached": "Maximum keys reached",
"macro_step_modifiers_description": "What modifiers (Shift/Ctrl/Alt/Meta) are pressed during this step.",
"macro_step_modifiers_label": "Modifiers",
"macro_step_no_matching_keys_found": "No matching keys found",
"macro_step_search_for_key": "Search for key…",
"macro_steps_description": "Keys/modifiers executed in sequence with a delay between each step.",
"macro_steps_label": "Steps",
"macros_add_description": "Create a new keyboard macro",
"macros_add_new": "Add New Macro",
"macros_create_first": "Create your first macro to get started",
"macros_created_success": "Macro \"{name}\" created successfully",
"macros_delete_confirm": "Are you sure you want to delete this macro? This action cannot be undone.",
"macros_delete_macro": "Delete Macro",
"macros_deleted_success": "Macro \"{name}\" deleted successfully",
"macros_deleting": "Deleting",
"macros_duplicate": "Duplicate",
"macros_duplicated_success": "Macro \"{name}\" duplicated successfully",
"macros_edit_description": "Modify your keyboard macro",
"macros_edit_title": "Edit Macro",
"macros_edit": "Edit",
"macros_failed_create": "Failed to create macro",
"macros_failed_create_error": "Failed to create macro: {error}",
"macros_failed_delete": "Failed to delete macro",
"macros_failed_delete_error": "Failed to delete macro: {error}",
"macros_failed_duplicate": "Failed to duplicate macro",
"macros_failed_duplicate_error": "Failed to duplicate macro: {error}",
"macros_failed_reorder": "Failed to reorder macros",
"macros_failed_reorder_error": "Failed to reorder macros: {error}",
"macros_failed_update": "Failed to update macro",
"macros_failed_update_error": "Failed to update macro: {error}",
"macros_invalid_data": "Invalid macro data",
"macros_maximum_macros_reached": "You have reached the maximum number of {maximum} macros allowed.",
"macros_move_down": "Move Down",
"macros_move_up": "Move Up",
"macros_no_macros_available": "No macros available",
"macros_no_macros_found": "No macros found",
"macros_order_updated": "Macro order updated successfully",
"macros_title": "Keyboard Macros",
"macros_updated_success": "Macro \"{name}\" updated successfully",
"macros_aria_delete": "Delete macro {name}",
"macros_aria_duplicate": "Duplicate macro {name}",
"macros_aria_edit": "Edit macro {name}",
"macros_aria_move_down": "Move {name} down",
"macros_aria_move_up": "Move {name} up",
"macros_confirm_delete_description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"macros_confirm_delete_title": "Delete Macro",
"macros_confirm_deleting": "Deleting…",
"macros_add_new_macro": "Add New Macro",
"macros_aria_add_new": "Add new macro",
"macros_create_first_headline": "Create Your First Macro",
"macros_create_first_description": "Combine keystrokes into a single action",
"macros_delay_only": "Delay only",
"macros_edit_button": "Edit",
"macros_loading": "Loading macros…",
"macros_max_reached": "Max Reached",
"metric_not_supported": "Metric not supported",
"metric_waiting_for_data": "Waiting for data…",
"mount_add_file_to_get_started": "Add a file to get started",
"mount_add_new_media": "Add New Media",
"mount_available_storage": "Available Storage",
"mount_button_back_to_overview": "Back to Overview",
"mount_button_cancel_upload": "Cancel Upload",
"mount_button_continue_upload": "Continue uploading",
"mount_button_mount_file": "Mount File",
"mount_button_mount_url": "Mount URL",
"mount_button_next": "Next",
"mount_button_previous": "Previous",
"mount_button_select": "Select",
"mount_button_showing_results": "Showing {from} to {to} of {total} results",
"mount_button_upload_new_image": "Upload a new image",
"mount_bytes_free": "{bytesFree} free",
"mount_bytes_used": "{bytesUsed} used",
"mount_calculating": "Calculating…",
"mount_click_to_select_file": "Click to select a file",
"mount_click_to_select_incomplete": "Click to select \"{name}\"",
"mount_confirm_delete": "Are you sure you want to delete {name}?",
"mount_continue_uploading_with_name": "Continue uploading \"{name}\"",
"mount_description_mode": "Choose how you want to mount your virtual media",
"mount_error_delete_file": "Error deleting file: {error}",
"mount_error_description": "An error occurred while attempting to mount the media. Please try again.",
"mount_error_get_storage_space": "Error getting storage space: {error}",
"mount_error_list_storage": "Error listing storage files: {error}",
"mount_error_title": "Mount Error",
"mount_get_state_error": "Failed to get virtual media state: {error}",
"mount_jetkvm_storage_description": "Mount previously uploaded files from the JetKVM storage",
"mount_jetkvm_storage": "JetKVM Storage Mount",
"mount_label_mount_as": "Mount as",
"mount_label_url_description": "Mount files from any public web address",
"mount_label_url": "URL Mount",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Mounted as",
"mount_mounted_from_storage": "Mounted from JetKVM Storage",
"mount_no_images_description": "Upload an image to start virtual media mounting.",
"mount_no_images_title": "No images available",
"mount_no_mounted_media": "No mounted media",
"mount_percentage_used": "{percentageUsed}% used",
"mount_please_select_file_to_upload": "Please select the file to upload.",
"mount_please_select_file": "Please select the file \"{name}\" to continue the upload.",
"mount_popular_images": "Popular images",
"mount_streaming_from_url": "Streaming from URL",
"mount_supported_formats": "Supported formats: ISO, IMG",
"mount_tag_experimental": "Experimental",
"mount_title_mode": "Virtual Media Source",
"mount_unmount_error": "Failed to unmount image: {error}",
"mount_unmount": "Unmount",
"mount_upload_description": "Select an image file to upload to JetKVM storage",
"mount_upload_error": "Upload error: {error}",
"mount_upload_failed_datachannel": "Failed to create data channel for file upload",
"mount_upload_failed_rtc": "Upload failed: {error}",
"mount_upload_successful": "Upload successful",
"mount_upload_title": "Upload New Image",
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
"mount_uploading_with_name": "Uploading {name}",
"mount_uploading": "Uploading…",
"mount_url_description": "Mount files from any public web address",
"mount_url_input_label": "Image URL",
"mount_url_mount": "URL Mount",
"mount_view_device_description": "Select an image to mount from the JetKVM storage",
"mount_view_device_title": "Mount from JetKVM Storage",
"mount_view_url_description": "Enter an URL to the image file to mount",
"mount_view_url_title": "Mount from URL",
"mount_virtual_media_description": "Mount an image to boot from or install an operating system.",
"mount_virtual_media_source_description": "Choose how you want to mount your virtual media",
"mount_virtual_media_source": "Virtual Media Source",
"mount_virtual_media": "Virtual Media",
"never_seen_online": "Never seen online",
"next": "Next",
"no_results_found": "No results found",
"not_available": "N/A",
"not_found": "Not found",
"ntp_servers": "NTP Servers",
"oh_no": "Oh no!",
"online": "Online",
"other_session_detected": "Another Active Session Detected",
"other_session_take_over": " Only one active session is supported at a time. Would you like to take over this session?",
"other_session_use_here_button": "Use Here",
"page_not_found_description": "The page you were looking for does not exist.",
"paste_modal_confirm_paste": "Confirm Paste",
"paste_modal_delay_between_keys": "Delay between keys",
"paste_modal_delay_out_of_range": "Delay must be between {min} and {max}",
"paste_modal_failed_paste": "Failed to paste text: {error}",
"paste_modal_invalid_chars_intro": "The following characters won't be pasted:",
"paste_modal_paste_from_host": "Paste from host",
"paste_modal_paste_text_description": "Paste text from your client to the remote host",
"paste_modal_paste_text": "Paste text",
"paste_modal_sending_using_layout": "Sending text using keyboard layout: {iso}-{name}",
"peer_connection_closed": "Closed",
"peer_connection_closing": "Closing",
"peer_connection_connected": "Connected",
"peer_connection_connecting": "Connecting",
"peer_connection_disconnected": "Disconnected",
"peer_connection_error": "Connection error",
"peer_connection_failed": "Connection failed",
"peer_connection_new": "Connecting",
"previous": "Previous",
"register_device_error": "There was an error {error} registering your device.",
"register_device_finish_button": "Finish Setup",
"register_device_name_description": "Name your device so you can easily identify it later. You can change this name at any time.",
"register_device_name_label": "Device Name",
"register_device_name_placeholder": "Plex Media Server",
"register_device_no_name": "Please specify a name",
"rename_device_button": "Rename Device",
"rename_device_description": "Properly name your device to easily identify it.",
"rename_device_error": "There was an error {error} renaming your device.",
"rename_device_headline": "Rename {name}",
"rename_device_new_name_label": "New device name",
"rename_device_new_name_placeholder": "Plex Media Server",
"rename_device_no_name": "Please specify a name",
"rename": "Rename",
"retry": "Retry",
"saving": "Saving…",
"search_placeholder": "Search…",
"serial_console_baud_rate": "Baud Rate",
"serial_console_configure_description": "Configure your serial console settings",
"serial_console_data_bits": "Data Bits",
"serial_console_get_settings_error": "Failed to get serial console settings: {error}",
"serial_console_open_console": "Open Console",
"serial_console_parity_even": "Even Parity",
"serial_console_parity_mark": "Mark Parity",
"serial_console_parity_none": "No Parity",
"serial_console_parity_odd": "Odd Parity",
"serial_console_parity_space": "Space Parity",
"serial_console_parity": "Parity",
"serial_console_set_settings_error": "Failed to set serial console settings to {settings}: {error}",
"serial_console_stop_bits": "Stop Bits",
"serial_console": "Serial Console",
"setting_remote_description": "Setting remote description",
"setting_remote_session_description": "Setting remote session description...",
"setting_up_connection_to_device": "Setting up connection to device...",
"something_went_wrong": "Something went wrong. Please try again later or contact support",
"step_counter_step": "Step {step}",
"subnet_mask": "Subnet Mask",
"troubleshoot_connection": "Troubleshoot Connection",
"unknown_error": "Unknown error",
"update_in_progress": "Update in Progress",
"updates_failed_check": "Failed to check for updates: {error}",
"updates_failed_get_device_version": "Failed to get device version: {error}",
"updating_leave_device_on": "Please don't turn off your device…",
"usb_config_custom": "Custom",
"usb_config_default": "JetKVM Default",
"usb_config_dell": "Dell Multimedia Pro Keyboard",
"usb_config_failed_load": "Failed to load USB Config: {error}",
"usb_config_failed_set": "Failed to set USB config: {error}",
"usb_config_identifiers_description": "USB device identifiers exposed to the target computer",
"usb_config_identifiers_title": "Identifiers",
"usb_config_logitech": "Logitech Universal Adapter",
"usb_config_manufacturer_label": "Manufacturer",
"usb_config_manufacturer_placeholder": "Enter Manufacturer",
"usb_config_microsoft": "Microsoft Wireless MultiMedia Keyboard",
"usb_config_product_id_label": "Product ID",
"usb_config_product_id_placeholder": "Enter Product ID",
"usb_config_product_name_label": "Product Name",
"usb_config_product_name_placeholder": "Enter Product Name",
"usb_config_restore_default": "Restore to Default",
"usb_config_serial_number_label": "Serial Number",
"usb_config_serial_number_placeholder": "Enter Serial Number",
"usb_config_set_success": "USB Config set to {manufacturer} {product}",
"usb_config_update_identifiers": "Update USB Identifiers",
"usb_config_vendor_id_label": "Vendor ID",
"usb_config_vendor_id_placeholder": "Enter Vendor ID",
"usb_device_classes_description": "USB device classes in the composite device",
"usb_device_classes_title": "Classes",
"usb_device_custom": "Custom",
"usb_device_description": "USB devices to emulate on the target computer",
"usb_device_enable_absolute_mouse_description": "Enable Absolute Mouse (Pointer)",
"usb_device_enable_absolute_mouse_title": "Enable Absolute Mouse (Pointer)",
"usb_device_enable_keyboard_description": "Enable Keyboard",
"usb_device_enable_keyboard_title": "Enable Keyboard",
"usb_device_enable_mass_storage_description": "Sometimes it might need to be disabled to prevent issues with certain devices",
"usb_device_enable_mass_storage_title": "Enable USB Mass Storage",
"usb_device_enable_relative_mouse_description": "Enable Relative Mouse",
"usb_device_enable_relative_mouse_title": "Enable Relative Mouse",
"usb_device_failed_load": "Failed to load USB devices: {error}",
"usb_device_failed_set": "Failed to set USB devices: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage",
"usb_device_keyboard_only": "Keyboard Only",
"usb_device_restore_default": "Restore to Default",
"usb_device_title": "USB Device",
"usb_device_update_classes": "Update USB Classes",
"usb_device_updated": "USB Devices updated",
"usb_state_connected": "Connected",
"usb_state_connecting": "Connecting",
"usb_state_disconnected": "Disconnected",
"usb_state_low_power_mode": "Low power mode",
"usb": "USB",
"video_overlay_autoplay_permissions_required": "Autoplay permissions required",
"video_overlay_conn_check_cables": "Check all cable connections for any loose or damaged wires",
"video_overlay_conn_ensure_network": "Ensure your network connection is stable and active",
"video_overlay_conn_restart": "Try restarting both the device and your computer",
"video_overlay_conn_verify_power": "Verify that the device is powered on and properly connected",
"video_overlay_connection_issue_title": "Connection Issue Detected",
"video_overlay_enable_autoplay_settings": "Please adjust browser settings to enable autoplay",
"video_overlay_hdmi_error_title": "HDMI signal error detected.",
"video_overlay_hdmi_incompatible_resolution": "Incompatible resolution or refresh rate settings",
"video_overlay_hdmi_loose_faulty": "A loose or faulty HDMI connection",
"video_overlay_hdmi_source_issue": "Issues with the source device's HDMI output",
"video_overlay_learn_more": "Learn more",
"video_overlay_loading_stream": "Loading video stream…",
"video_overlay_manually_start_stream": "Manually start stream",
"video_overlay_no_hdmi_adapter_compat": "If using an adapter, ensure it's compatible and functioning correctly",
"video_overlay_no_hdmi_ensure_cable": "Ensure the HDMI cable securely connected at both ends",
"video_overlay_no_hdmi_ensure_power": "Ensure source device is powered on and outputting a signal",
"video_overlay_no_hdmi_signal": "No HDMI signal detected.",
"video_overlay_pointerlock_click_to_enable": "Click on the video to enable mouse control",
"video_overlay_retrying_connection": "Retrying connection…",
"video_overlay_troubleshooting_guide": "Troubleshooting Guide",
"video_overlay_try_again": "Try again",
"video_pointer_lock_disabled": "Pointer lock disabled",
"video_pointer_lock_enabled": "Pointer lock enabled — press Escape to unlock",
"view_details": "View Details",
"virtual_keyboard_description": "Use the virtual keyboard to send special keys or key combinations to the remote computer.",
"virtual_keyboard_header": "Virtual Keyboard",
"wake_on_lan_add_device_back": "Back",
"wake_on_lan_add_device_device_name": "Device Name",
"wake_on_lan_add_device_example_device_name": "Plex Media Server",
"wake_on_lan_add_device_mac_address": "MAC Address",
"wake_on_lan_add_device_save_device": "Save Device",
"wake_on_lan_description": "Send a Magic Packet to wake up a remote device.",
"wake_on_lan_device_list_add_new_device": "Add New Device",
"wake_on_lan_device_list_delete_device": "Delete device",
"wake_on_lan_device_list_wake": "Wake",
"wake_on_lan_empty_add_device_to_start": "Add a device to start using Wake-on-LAN",
"wake_on_lan_empty_add_new_device": "Add New Device",
"wake_on_lan_empty_no_devices_added": "No devices added",
"wake_on_lan_failed_add_device": "Failed to add device",
"wake_on_lan_failed_send_magic": "Failed to send Magic Packet",
"wake_on_lan_invalid_mac": "Invalid MAC address",
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
"wake_on_lan": "Wake On LAN",
"welcome_to_jetkvm_description": "Control any computer remotely",
"welcome_to_jetkvm": "Welcome to JetKVM"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "¡Oh, no!",
"something_went_wrong": "Algo salió mal. Inténtalo de nuevo más tarde o contacta con el servicio de asistencia.",
"jetkvm_logo": "Logotipo de JetKVM",
"load": "Carga",
"unknown_error": "Error desconocido",
"action_bar_virtual_media": "Medios virtuales",
"action_bar_paste_text": "Pegar texto",
"action_bar_web_terminal": "Terminal web",
"action_bar_wake_on_lan": "Activación en LAN",
"action_bar_virtual_keyboard": "Teclado virtual",
"action_bar_extension": "Extensión",
"action_bar_connection_stats": "Estadísticas de conexión",
"action_bar_settings": "Ajustes",
"action_bar_fullscreen": "Pantalla completa",
"action_bar_exit_fullscreen": "Salir de pantalla completa",
"extensions_popover_extensions": "Extensiones",
"extension_popover_set_error_notification": "No se pudo establecer la extensión activa: {error}",
"extension_popover_unload_extension": "Extensión de descarga",
"extension_popover_load_and_manage_extensions": "Cargar y administrar sus extensiones",
"extensions_atx_power_control": "Control de alimentación ATX",
"extensions_atx_power_control_description": "Controle el estado de energía de su máquina a través del control de energía ATX.",
"extensions_dc_power_control": "Control de potencia de CC",
"extensions_dc_power_control_description": "Controle su extensión de alimentación de CC",
"extension_serial_console": "Consola serial",
"extension_serial_console_description": "Acceda a la extensión de su consola serie",
"atx_power_control_get_state_error": "No se pudo obtener el estado de energía ATX: {error}",
"atx_power_control_send_action_error": "No se pudo enviar la acción de alimentación ATX {action} : {error}",
"atx_power_control_power_button": "Fuerza",
"atx_power_control_short_power_button": "Prensa corta",
"atx_power_control_long_power_button": "Pulsación larga",
"atx_power_control_reset_button": "Reiniciar",
"atx_power_control_power_led": "LED de encendido",
"atx_power_control_hdd_led": "LED del disco duro",
"dc_power_control_get_state_error": "No se pudo obtener el estado de la alimentación de CC: {error}",
"dc_power_control_set_power_state_error": "No se pudo enviar el estado de alimentación de CC a {enabled} : {error}",
"dc_power_control_set_restore_state_error": "No se pudo enviar el estado de restauración de energía de CC a {state} : {error}",
"dc_power_control_power_on_button": "Encendido",
"dc_power_control_power_off_button": "Apagado",
"dc_power_control_restore_power_state": "Restaurar pérdida de energía",
"dc_power_control_power_on_state": "Encendido",
"dc_power_control_power_off_state": "Apagado",
"dc_power_control_voltage": "Voltaje",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Amperio",
"dc_power_control_current_unit": "A",
"dc_power_control_power": "Vatio",
"dc_power_control_power_unit": "O",
"serial_console_get_state_error": "No se pudo obtener la configuración de la consola serial: {error}",
"serial_console_set_power_state_error": "No se pudieron establecer los ajustes de la consola serial en {settings} : {error}",
"serial_console_configure_description": "Configure los ajustes de su consola serie",
"serial_console_open_console": "Consola abierta",
"serial_console_baud_rate": "Tasa de Baud",
"serial_console_data_bits": "Bits de datos",
"serial_console_stop_bits": "Bits de parada",
"serial_console_parity": "Paridad",
"serial_console_parity_even": "Paridad uniforme",
"serial_console_parity_odd": "Paridad impar",
"serial_console_parity_none": "Sin paridad",
"serial_console_parity_mark": "Paridad de marca",
"serial_console_parity_space": "Paridad espacial",
"serial_console_get_settings_error": "No se pudo obtener la configuración de la consola serial: {error}",
"serial_console_set_settings_error": "No se pudieron establecer los ajustes de la consola serial en {settings} : {error}"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "Oh non!",
"something_went_wrong": "Une erreur s'est produite. Veuillez réessayer ultérieurement ou contacter le support.",
"jetkvm_logo": "Logo JetKVM",
"load": "Charger",
"unknown_error": "Erreur inconnue",
"action_bar_virtual_media": "Médias virtuels",
"action_bar_paste_text": "Coller du texte",
"action_bar_web_terminal": "Terminal Web",
"action_bar_wake_on_lan": "Réveil sur LAN",
"action_bar_virtual_keyboard": "Clavier virtuel",
"action_bar_extension": "Extension",
"action_bar_connection_stats": "Statistiques de connexion",
"action_bar_settings": "Paramètres",
"action_bar_fullscreen": "Plein écran",
"action_bar_exit_fullscreen": "Quitter le plein écran",
"extensions_popover_extensions": "Extensions",
"extension_popover_set_error_notification": "Échec de la définition de l'extension active: {error}",
"extension_popover_unload_extension": "Extension de déchargement",
"extension_popover_load_and_manage_extensions": "Chargez et gérez vos extensions",
"extensions_atx_power_control": "Contrôle d'alimentation ATX",
"extensions_atx_power_control_description": "Contrôlez l'état d'alimentation de votre machine via le contrôle d'alimentation ATX.",
"extensions_dc_power_control": "Contrôle de l'alimentation CC",
"extensions_dc_power_control_description": "Contrôlez votre extension d'alimentation CC",
"extension_serial_console": "Console série",
"extension_serial_console_description": "Accédez à votre extension de console série",
"atx_power_control_get_state_error": "Échec de l'obtention de l'état d'alimentation ATX : {error}",
"atx_power_control_send_action_error": "Échec de l'envoi de l'action d'alimentation ATX {action} : {error}",
"atx_power_control_power_button": "Pouvoir",
"atx_power_control_short_power_button": "Appui court",
"atx_power_control_long_power_button": "Appui long",
"atx_power_control_reset_button": "Réinitialiser",
"atx_power_control_power_led": "LED d'alimentation",
"atx_power_control_hdd_led": "Voyant du disque dur",
"dc_power_control_get_state_error": "Échec de l'obtention de l'état d'alimentation CC : {error}",
"dc_power_control_set_power_state_error": "Échec de l'envoi de l'état d'alimentation CC à {enabled} : {error}",
"dc_power_control_set_restore_state_error": "Échec de l'envoi de l'état de restauration de l'alimentation CC à {state} : {error}",
"dc_power_control_power_on_button": "Mise sous tension",
"dc_power_control_power_off_button": "Éteindre",
"dc_power_control_restore_power_state": "Restaurer la perte de puissance",
"dc_power_control_power_on_state": "Mise sous tension",
"dc_power_control_power_off_state": "Éteindre",
"dc_power_control_voltage": "Tension",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Ampère",
"dc_power_control_current_unit": "UN",
"dc_power_control_power": "Pouvoir",
"dc_power_control_power_unit": "W",
"serial_console_get_state_error": "Échec de l'obtention des paramètres de la console série : {error}",
"serial_console_set_power_state_error": "Échec de la définition des paramètres de la console série sur {settings} : {error}",
"serial_console_configure_description": "Configurez les paramètres de votre console série",
"serial_console_open_console": "Ouvrir la console",
"serial_console_baud_rate": "Débit en bauds",
"serial_console_data_bits": "Bits de données",
"serial_console_stop_bits": "Bits d'arrêt",
"serial_console_parity": "Parité",
"serial_console_parity_even": "Parité égale",
"serial_console_parity_odd": "Parité impaire",
"serial_console_parity_none": "Pas de parité",
"serial_console_parity_mark": "Marquer la parité",
"serial_console_parity_space": "Parité spatiale",
"serial_console_get_settings_error": "Échec de l'obtention des paramètres de la console série : {error}",
"serial_console_set_settings_error": "Échec de la définition des paramètres de la console série sur {settings} : {error}"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "Oh no!",
"something_went_wrong": "Qualcosa è andato storto. Riprova più tardi o contatta l'assistenza.",
"jetkvm_logo": "Logo JetKVM",
"load": "Carico",
"unknown_error": "Errore sconosciuto",
"action_bar_virtual_media": "Media virtuali",
"action_bar_paste_text": "Incolla il testo",
"action_bar_web_terminal": "Terminale Web",
"action_bar_wake_on_lan": "Wake on LAN",
"action_bar_virtual_keyboard": "Tastiera virtuale",
"action_bar_extension": "Estensione",
"action_bar_connection_stats": "Statistiche di connessione",
"action_bar_settings": "Impostazioni",
"action_bar_fullscreen": "A schermo intero",
"action_bar_exit_fullscreen": "Esci dalla modalità a schermo intero",
"extensions_popover_extensions": "Estensioni",
"extension_popover_set_error_notification": "Impossibile impostare l'estensione attiva: {error}",
"extension_popover_unload_extension": "Estensione di scaricamento",
"extension_popover_load_and_manage_extensions": "Carica e gestisci le tue estensioni",
"extensions_atx_power_control": "Controllo di potenza ATX",
"extensions_atx_power_control_description": "Controlla lo stato di alimentazione del tuo computer tramite il controllo di alimentazione ATX.",
"extensions_dc_power_control": "Controllo di potenza CC",
"extensions_dc_power_control_description": "Controlla la tua estensione di alimentazione CC",
"extension_serial_console": "Console seriale",
"extension_serial_console_description": "Accedi all'estensione della tua console seriale",
"atx_power_control_get_state_error": "Impossibile ottenere lo stato di alimentazione ATX: {error}",
"atx_power_control_send_action_error": "Impossibile inviare l'azione di alimentazione ATX {action} : {error}",
"atx_power_control_power_button": "Energia",
"atx_power_control_short_power_button": "Pressione breve",
"atx_power_control_long_power_button": "Pressione lunga",
"atx_power_control_reset_button": "Reset",
"atx_power_control_power_led": "LED di potenza",
"atx_power_control_hdd_led": "LED dell'HDD",
"dc_power_control_get_state_error": "Impossibile ottenere lo stato di alimentazione CC: {error}",
"dc_power_control_set_power_state_error": "Impossibile inviare lo stato di alimentazione CC a {enabled} : {error}",
"dc_power_control_set_restore_state_error": "Impossibile inviare lo stato di ripristino dell'alimentazione CC a {state} : {error}",
"dc_power_control_power_on_button": "Accensione",
"dc_power_control_power_off_button": "Spegnimento",
"dc_power_control_restore_power_state": "Ripristinare la perdita di potenza",
"dc_power_control_power_on_state": "Accensione",
"dc_power_control_power_off_state": "Spegnimento",
"dc_power_control_voltage": "Voltaggio",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Attuale",
"dc_power_control_current_unit": "UN",
"dc_power_control_power": "Energia",
"dc_power_control_power_unit": "O",
"serial_console_get_state_error": "Impossibile ottenere le impostazioni della console seriale: {error}",
"serial_console_set_power_state_error": "Impossibile impostare le impostazioni della console seriale su {settings} : {error}",
"serial_console_configure_description": "Configura le impostazioni della tua console seriale",
"serial_console_open_console": "Apri console",
"serial_console_baud_rate": "Velocità in baud",
"serial_console_data_bits": "Bit di dati",
"serial_console_stop_bits": "Bit di stop",
"serial_console_parity": "Parità",
"serial_console_parity_even": "Parità pari",
"serial_console_parity_odd": "Parità dispari",
"serial_console_parity_none": "Nessuna parità",
"serial_console_parity_mark": "Segna la parità",
"serial_console_parity_space": "Parità spaziale",
"serial_console_get_settings_error": "Impossibile ottenere le impostazioni della console seriale: {error}",
"serial_console_set_settings_error": "Impossibile impostare le impostazioni della console seriale su {settings} : {error}"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "Å nei!",
"something_went_wrong": "Noe gikk galt. Prøv igjen senere, eller kontakt kundestøtte.",
"jetkvm_logo": "JetKVM-logo",
"load": "Laste",
"unknown_error": "Ukjent feil",
"action_bar_virtual_media": "Virtuelle medier",
"action_bar_paste_text": "Lim inn tekst",
"action_bar_web_terminal": "Nettterminal",
"action_bar_wake_on_lan": "Vekk på LAN",
"action_bar_virtual_keyboard": "Virtuelt tastatur",
"action_bar_extension": "Forlengelse",
"action_bar_connection_stats": "Tilkoblingsstatistikk",
"action_bar_settings": "Innstillinger",
"action_bar_fullscreen": "Fullskjerm",
"action_bar_exit_fullscreen": "Avslutt fullskjerm",
"extensions_popover_extensions": "Utvidelser",
"extension_popover_set_error_notification": "Klarte ikke å angi aktiv utvidelse: {error}",
"extension_popover_unload_extension": "Fjern utvidelse",
"extension_popover_load_and_manage_extensions": "Last inn og administrer utvidelsene dine",
"extensions_atx_power_control": "ATX-strømstyring",
"extensions_atx_power_control_description": "Kontroller maskinens strømstatus via ATX-strømkontroll.",
"extensions_dc_power_control": "DC-strømkontroll",
"extensions_dc_power_control_description": "Kontroller DC-strømutvidelsen din",
"extension_serial_console": "Seriell konsoll",
"extension_serial_console_description": "Få tilgang til seriekonsollutvidelsen din",
"atx_power_control_get_state_error": "Klarte ikke å hente ATX-strømstatus: {error}",
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømhandling {action} : {error}",
"atx_power_control_power_button": "Makt",
"atx_power_control_short_power_button": "Kort trykk",
"atx_power_control_long_power_button": "Langt trykk",
"atx_power_control_reset_button": "Tilbakestill",
"atx_power_control_power_led": "Strøm-LED",
"atx_power_control_hdd_led": "HDD-LED",
"dc_power_control_get_state_error": "Klarte ikke å hente likestrømsstatus: {error}",
"dc_power_control_set_power_state_error": "Kunne ikke sende likestrømsstatus til {enabled} : {error}",
"dc_power_control_set_restore_state_error": "Kunne ikke sende gjenopprettingsstatus for likestrøm til {state} : {error}",
"dc_power_control_power_on_button": "Slå på",
"dc_power_control_power_off_button": "Slå av",
"dc_power_control_restore_power_state": "Gjenopprett strømtap",
"dc_power_control_power_on_state": "Slå PÅ",
"dc_power_control_power_off_state": "Slå av",
"dc_power_control_voltage": "Spenning",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Nåværende",
"dc_power_control_current_unit": "EN",
"dc_power_control_power": "Makt",
"dc_power_control_power_unit": "V",
"serial_console_get_state_error": "Klarte ikke å hente innstillinger for seriell konsoll: {error}",
"serial_console_set_power_state_error": "Klarte ikke å sette innstillingene for seriell konsoll til {settings} : {error}",
"serial_console_configure_description": "Konfigurer innstillingene for seriekonsollen",
"serial_console_open_console": "Åpne konsollen",
"serial_console_baud_rate": "Baudhastighet",
"serial_console_data_bits": "Databiter",
"serial_console_stop_bits": "Stoppbiter",
"serial_console_parity": "Paritet",
"serial_console_parity_even": "Paritet",
"serial_console_parity_odd": "Oddeparitet",
"serial_console_parity_none": "Ingen paritet",
"serial_console_parity_mark": "Mark Paritet",
"serial_console_parity_space": "Romparitet",
"serial_console_get_settings_error": "Klarte ikke å hente innstillinger for seriell konsoll: {error}",
"serial_console_set_settings_error": "Klarte ikke å sette innstillingene for seriell konsoll til {settings} : {error}"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "nej då!",
"something_went_wrong": "Något gick fel. Försök igen senare eller kontakta supporten.",
"jetkvm_logo": "JetKVM-logotyp",
"load": "Ladda",
"unknown_error": "Okänt fel",
"action_bar_virtual_media": "Virtuella medier",
"action_bar_paste_text": "Klistra in text",
"action_bar_web_terminal": "Webbterminal",
"action_bar_wake_on_lan": "Vakna på LAN",
"action_bar_virtual_keyboard": "Virtuellt tangentbord",
"action_bar_extension": "Förlängning",
"action_bar_connection_stats": "Anslutningsstatistik",
"action_bar_settings": "Inställningar",
"action_bar_fullscreen": "Helskärm",
"action_bar_exit_fullscreen": "Avsluta helskärm",
"extensions_popover_extensions": "Tillägg",
"extension_popover_set_error_notification": "Misslyckades med att ange aktivt tillägg: {error}",
"extension_popover_unload_extension": "Avlasta tillägg",
"extension_popover_load_and_manage_extensions": "Ladda och hantera dina tillägg",
"extensions_atx_power_control": "ATX-strömkontroll",
"extensions_atx_power_control_description": "Styr din maskins strömförsörjning via ATX-strömkontroll.",
"extensions_dc_power_control": "DC-strömstyrning",
"extensions_dc_power_control_description": "Styr din DC-strömförlängning",
"extension_serial_console": "Seriell konsol",
"extension_serial_console_description": "Åtkomst till din seriella konsoltillägg",
"atx_power_control_get_state_error": "Misslyckades med att hämta ATX-strömstatus: {error}",
"atx_power_control_send_action_error": "Misslyckades med att skicka ATX-strömåtgärd {action} : {error}",
"atx_power_control_power_button": "Driva",
"atx_power_control_short_power_button": "Kort tryck",
"atx_power_control_long_power_button": "Långt tryck",
"atx_power_control_reset_button": "Återställa",
"atx_power_control_power_led": "Ström-LED",
"atx_power_control_hdd_led": "Hårddisk-LED",
"dc_power_control_get_state_error": "Misslyckades med att hämta likströmsstatus: {error}",
"dc_power_control_set_power_state_error": "Misslyckades med att skicka likströmsstatus till {enabled} : {error}",
"dc_power_control_set_restore_state_error": "Misslyckades med att skicka återställningsstatus för likström till {state} : {error}",
"dc_power_control_power_on_button": "Slå på",
"dc_power_control_power_off_button": "Stäng av",
"dc_power_control_restore_power_state": "Återställ strömförlust",
"dc_power_control_power_on_state": "Slå på",
"dc_power_control_power_off_state": "Stäng av",
"dc_power_control_voltage": "Spänning",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "Nuvarande",
"dc_power_control_current_unit": "En",
"dc_power_control_power": "Driva",
"dc_power_control_power_unit": "V",
"serial_console_get_state_error": "Misslyckades med att hämta inställningar för seriekonsolen: {error}",
"serial_console_set_power_state_error": "Misslyckades med att ställa in seriekonsolinställningarna till {settings} : {error}",
"serial_console_configure_description": "Konfigurera dina seriella konsolinställningar",
"serial_console_open_console": "Öppna konsolen",
"serial_console_baud_rate": "Baudhastighet",
"serial_console_data_bits": "Databitar",
"serial_console_stop_bits": "Stoppbitar",
"serial_console_parity": "Paritet",
"serial_console_parity_even": "Jämn paritet",
"serial_console_parity_odd": "Udda paritet",
"serial_console_parity_none": "Ingen paritet",
"serial_console_parity_mark": "Markera paritet",
"serial_console_parity_space": "Rymdparitet",
"serial_console_get_settings_error": "Misslyckades med att hämta inställningar för seriekonsolen: {error}",
"serial_console_set_settings_error": "Misslyckades med att ställa in seriekonsolinställningarna till {settings} : {error}"
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"jetkvm": "JetKVM",
"oh_no": "噢不!",
"something_went_wrong": "出了点问题。请稍后重试或联系客服",
"jetkvm_logo": "JetKVM 徽标",
"load": "加载",
"unknown_error": "未知错误",
"action_bar_virtual_media": "虚拟媒体",
"action_bar_paste_text": "粘贴文本",
"action_bar_web_terminal": "网页终端",
"action_bar_wake_on_lan": "局域网唤醒",
"action_bar_virtual_keyboard": "虚拟键盘",
"action_bar_extension": "扩展",
"action_bar_connection_stats": "连接统计",
"action_bar_settings": "设置",
"action_bar_fullscreen": "全屏",
"action_bar_exit_fullscreen": "退出全屏",
"extensions_popover_extensions": "扩展",
"extension_popover_set_error_notification": "无法设置活动扩展:{error}",
"extension_popover_unload_extension": "卸载扩展",
"extension_popover_load_and_manage_extensions": "加载和管理您的扩展",
"extensions_atx_power_control": "ATX 电源控制",
"extensions_atx_power_control_description": "通过 ATX 电源控制来控制机器的电源状态。",
"extensions_dc_power_control": "直流电源控制",
"extensions_dc_power_control_description": "控制您的直流电源扩展",
"extension_serial_console": "串行控制台",
"extension_serial_console_description": "访问串行控制台扩展",
"atx_power_control_get_state_error": "无法获取 ATX 电源状态:{error}",
"atx_power_control_send_action_error": "无法发送 ATX 电源操作 {action} : {error}",
"atx_power_control_power_button": "电源",
"atx_power_control_short_power_button": "短按",
"atx_power_control_long_power_button": "长按",
"atx_power_control_reset_button": "重置",
"atx_power_control_power_led": "电源 LED",
"atx_power_control_hdd_led": "硬盘指示灯",
"dc_power_control_get_state_error": "无法获取直流电源状态:{error}",
"dc_power_control_set_power_state_error": "无法将直流电源状态发送到 {enabled} : {error}",
"dc_power_control_set_restore_state_error": "无法将直流电源恢复状态发送到 {state} : {error}",
"dc_power_control_power_on_button": "开机",
"dc_power_control_power_off_button": "关闭电源",
"dc_power_control_restore_power_state": "恢复断电",
"dc_power_control_power_on_state": "开启电源",
"dc_power_control_power_off_state": "关闭电源",
"dc_power_control_voltage": "电压",
"dc_power_control_voltage_unit": "V",
"dc_power_control_current": "电流",
"dc_power_control_current_unit": "A",
"dc_power_control_power": "瓦特",
"dc_power_control_power_unit": "W",
"serial_console_get_state_error": "无法获取串行控制台设置: {error}",
"serial_console_set_power_state_error": "无法将串行控制台设置设置为 {settings} : {error}",
"serial_console_configure_description": "配置串行控制台设置",
"serial_console_open_console": "打开控制台",
"serial_console_baud_rate": "波特率",
"serial_console_data_bits": "数据位",
"serial_console_stop_bits": "停止位",
"serial_console_parity": "奇偶校验位",
"serial_console_parity_even": "偶校验",
"serial_console_parity_odd": "奇校验",
"serial_console_parity_none": "无",
"serial_console_parity_mark": "Mark",
"serial_console_parity_space": "Space",
"serial_console_get_settings_error": "无法获取串行控制台设置: {error}",
"serial_console_set_settings_error": "无法将串行控制台设置设置为 {settings} : {error}"
}

1374
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "kvm-ui",
"private": true,
"version": "2025.10.01.1900",
"version": "2025.10.13.2055",
"type": "module",
"engines": {
"node": "^22.15.0"
@ -11,12 +11,14 @@
"dev:ssl": "USE_SSL=true ./dev_device.sh",
"dev:cloud": "vite dev --mode=cloud-development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:staging": "tsc && vite build --mode=cloud-staging",
"build:prod": "tsc && vite build --mode=cloud-production",
"lint": "eslint './src/**/*.{ts,tsx}'",
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
"preview": "vite preview"
"build:device": "npm run paraglide && tsc && vite build --mode=device --emptyOutDir",
"build:staging": "npm run paraglide && tsc && vite build --mode=cloud-staging",
"build:prod": "npm run paraglide && tsc && vite build --mode=cloud-production",
"lint": "npm run paraglide && eslint './src/**/*.{ts,tsx}'",
"lint:fix": "npm run paraglide && eslint './src/**/*.{ts,tsx}' --fix",
"paraglide": "paraglide-js compile --project ./localization/jetKVM.UI.inlang --outdir ./localization/paraglide",
"validate": "inlang validate --project ./localization/jetKVM.UI.inlang",
"machine-translate": "inlang machine translate --project ./localization/jetKVM.UI.inlang"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
@ -33,16 +35,16 @@
"dayjs": "^1.11.18",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.22",
"framer-motion": "^12.23.24",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"react": "^19.1.1",
"react": "^19.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-router": "^7.9.3",
"react-simple-keyboard": "^3.8.125",
"react-router": "^7.9.4",
"react-simple-keyboard": "^3.8.130",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10",
"recharts": "^3.2.1",
@ -54,32 +56,37 @@
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.36.0",
"@eslint/js": "^9.37.0",
"@inlang/cli": "^3.0.12",
"@inlang/paraglide-js": "^2.4.0",
"@inlang/plugin-m-function-matcher": "^2.1.0",
"@inlang/plugin-message-format": "^4.0.0",
"@inlang/sdk": "^2.4.9",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.14",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.14",
"@types/react": "^19.1.17",
"@types/react-dom": "^19.1.10",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/semver": "^7.7.1",
"@types/validator": "^13.15.3",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint": "^9.37.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.23",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vite": "^7.1.9",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@ -1,24 +1,25 @@
import { Fragment, useCallback, useRef } from "react";
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import {
useHidStore,
useMountMediaStore,
useSettingsStore,
useUiStore,
} from "@/hooks/stores";
} from "@hooks/stores";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button";
import Container from "@components/Container";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import PasteModal from "@components/popovers/PasteModal";
import WakeOnLanModal from "@components/popovers/WakeOnLan/Index";
import MountPopopover from "@components/popovers/MountPopover";
import ExtensionPopover from "@components/popovers/ExtensionPopover";
import { m } from "@localizations/messages.js";
export default function Actionbar({
requestFullscreen,
@ -28,10 +29,7 @@ export default function Actionbar({
const { navigateTo } = useDeviceUiNavigation();
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const { remoteVirtualMediaState } = useMountMediaStore();
const { developerMode } = useSettingsStore();
// This is the only way to get a reliable state change for the popover
@ -64,7 +62,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Web Terminal"
text={m.action_bar_web_terminal()}
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
@ -74,7 +72,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Paste text"
text={m.action_bar_paste_text()}
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableVideoFocusTrap(true);
@ -105,7 +103,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Virtual Media"
text={m.action_bar_virtual_media()}
LeadingIcon={({ className }) => {
return (
<>
@ -148,7 +146,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Wake on LAN"
text={m.action_bar_wake_on_lan()}
onClick={() => {
setDisableVideoFocusTrap(true);
}}
@ -198,7 +196,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
text={m.action_bar_virtual_keyboard()}
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
@ -211,7 +209,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Extension"
text={m.action_bar_extension()}
LeadingIcon={LuCable}
onClick={() => {
setDisableVideoFocusTrap(true);
@ -237,7 +235,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
text={m.action_bar_virtual_keyboard()}
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
@ -246,7 +244,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Connection Stats"
text={m.action_bar_connection_stats()}
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}
@ -262,7 +260,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Settings"
text={m.action_bar_settings()}
LeadingIcon={LuSettings}
onClick={() => {
setDisableVideoFocusTrap(true);
@ -276,7 +274,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Fullscreen"
text={m.action_bar_fullscreen()}
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>

View File

@ -1,10 +1,9 @@
import React, { JSX } from "react";
import { Link, useNavigation } from "react-router";
import type { FetcherWithComponents, LinkProps } from "react-router";
import { Link, type FetcherWithComponents, type LinkProps, useNavigation } from "react-router";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import ExtLink from "@components/ExtLink";
import LoadingSpinner from "@components/LoadingSpinner";
const sizes = {
XS: "h-[28px] px-2 text-xs",

View File

@ -2,7 +2,7 @@ import type { Ref } from "react";
import React, { forwardRef, JSX } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import FieldLabel from "@components/FieldLabel";
import { cva, cx } from "@/cva.config";
const sizes = {

View File

@ -7,10 +7,10 @@ import {
ComboboxOptions,
} from "@headlessui/react";
import { m } from "@localizations/messages.js";
import Card from "@components/Card";
import { cva } from "@/cva.config";
import Card from "./Card";
export interface ComboboxOption {
value: string;
label: string;
@ -44,11 +44,11 @@ export function Combobox({
displayValue,
options,
disabled = false,
placeholder = "Search...",
emptyMessage = "No results found",
placeholder = m.search_placeholder(),
emptyMessage = m.no_results_found(),
size = "MD",
onChange,
disabledMessage = "Input disabled",
disabledMessage = m.input_disabled(),
...otherProps
}: ComboboxProps) {
const inputRef = useRef<HTMLInputElement>(null);

View File

@ -4,8 +4,9 @@ import {
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
import { m } from "@localizations/messages.js";
import { Button } from "@components/Button";
import Modal from "@components/Modal";
import { cx } from "@/cva.config";
type Variant = "danger" | "success" | "warning" | "info";
@ -63,8 +64,8 @@ export function ConfirmDialog({
title,
description,
variant = "info",
confirmText = "Confirm",
cancelText = "Cancel",
confirmText = m.confirm(),
cancelText = m.cancel(),
onConfirm,
isConfirming = false,
}: ConfirmDialogProps) {

View File

@ -1,9 +1,10 @@
import { LuRefreshCcw } from "react-icons/lu";
import { Button } from "@/components/Button";
import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { LifeTimeLabel } from "@routes/devices.$id.settings.network";
import { NetworkState } from "@hooks/stores";
import { m } from "@localizations/messages.js";
export default function DhcpLeaseCard({
networkState,
@ -17,7 +18,7 @@ export default function DhcpLeaseCard({
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
{m.dhcp_lease_header()}
</h3>
<div className="flex gap-x-6 gap-y-2">
@ -25,7 +26,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
{m.ip_address()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
@ -36,7 +37,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
{m.subnet_mask()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
@ -47,7 +48,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
{m.dns_servers()}
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
@ -58,7 +59,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
{m.dhcp_lease_broadcast()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
@ -69,7 +70,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
{m.dhcp_lease_domain()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
@ -81,7 +82,7 @@ export default function DhcpLeaseCard({
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
{m.ntp_servers()}
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
@ -94,7 +95,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
{m.dhcp_lease_hostname()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
@ -108,7 +109,7 @@ export default function DhcpLeaseCard({
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
{m.dhcp_lease_gateway()}
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
@ -121,7 +122,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
{m.dhcp_server()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
@ -132,7 +133,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
{m.dhcp_lease_lease_expires()}
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
@ -146,7 +147,7 @@ export default function DhcpLeaseCard({
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">MTU</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.mtu}
{m.dhcp_lease_maximum_transfer_unit()}
</span>
</div>
)}
@ -155,7 +156,7 @@ export default function DhcpLeaseCard({
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">TTL</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ttl}
{m.dhcp_lease_time_to_live()}
</span>
</div>
)}
@ -163,7 +164,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
{m.dhcp_lease_boot_next_server()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
@ -174,7 +175,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
{m.dhcp_lease_boot_server_name()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
@ -185,7 +186,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
{m.dhcp_lease_boot_file()}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
@ -194,13 +195,12 @@ export default function DhcpLeaseCard({
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
text={m.dhcp_lease_renew()}
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>

View File

@ -1,8 +1,7 @@
import React from "react";
import { GridCard } from "@/components/Card";
import { cx } from "../cva.config";
import { GridCard } from "@components/Card";
import { cx } from "@/cva.config";
interface Props {
IconElm?: React.FC<{ className: string | undefined }>;

View File

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
export function FeatureFlag({
minAppVersion,

View File

@ -1,7 +1,7 @@
import React from "react";
import clsx from "clsx";
import { useNavigation } from "react-router";
import type { FetcherWithComponents } from "react-router";
import clsx from "clsx";
export default function Fieldset({
children,

View File

@ -4,19 +4,18 @@ import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/1
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import LogoBlueIcon from "@assets/logo-blue.svg";
import LogoWhiteIcon from "@assets/logo-white.svg";
import { useHidStore, useRTCStore, useUserStore } from "@hooks/stores";
import Card from "@components/Card";
import Container from "@components/Container";
import { LinkButton } from "@components/Button";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import USBStateStatus from "@components/USBStateStatus";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "../api";
import { isOnDevice } from "../main";
import { LinkButton } from "./Button";
import api from "@/api";
import { isOnDevice } from "@/main";
import { m } from "@localizations/messages.js";
interface NavbarProps {
isLoggedIn: boolean;
@ -131,7 +130,7 @@ export default function DashboardNavbar({
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<div className="p-2">
<div className="font-display text-xs">
Logged in as
{m.logged_in_as()}
</div>
<div className="font-display max-w-[200px] truncate text-sm font-semibold">
{userEmail}
@ -146,7 +145,7 @@ export default function DashboardNavbar({
>
<button className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="size-4" />
<div className="font-display">Log out</div>
<div className="font-display">{m.log_out()}</div>
</button>
</div>
</Card>

View File

@ -1,6 +1,5 @@
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { cx } from "@/cva.config";
import {
useHidStore,
useMouseStore,
@ -8,9 +7,11 @@ import {
useSettingsStore,
useVideoStore,
VideoState
} from "@/hooks/stores";
} from "@hooks/stores";
import { useHidRpc } from "@hooks/useHidRpc";
import { keys, modifiers } from "@/keyboardMappings";
import { useHidRpc } from "@/hooks/useHidRpc";
import { cx } from "@/cva.config";
import { m } from "@localizations/messages.js";
export default function InfoBar() {
const { keysDownState } = useHidStore();
@ -25,29 +26,23 @@ export default function InfoBar() {
(state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
);
const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
const { isPasteInProgress } = useHidStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = (e: Event) =>
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const { keyboardLedState, usbState } = useHidStore();
const { isTurnServerInUse } = useRTCStore();
const { hdmiState } = useVideoStore();
const displayKeys = useMemo(() => {
if (!showPressedKeys)
return "";
if (!showPressedKeys) return "";
const activeModifierMask = keysDownState.modifier || 0;
const keysDown = keysDownState.keys || [];
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
const modifierNames = Object.entries(modifiers)
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name]) => name);
const keyNames = Object.entries(keys)
.filter(([_, value]) => keysDown.includes(value))
.map(([name]) => name);
return [...modifierNames, ...keyNames].join(", ");
}, [keysDownState, showPressedKeys]);
@ -59,76 +54,75 @@ export default function InfoBar() {
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs font-semibold">{m.info_resolution()}</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs font-semibold">{m.info_video_size()}</span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{(debugMode && mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
{mouseX},{mouseY}
</span>
<span className="text-xs font-semibold">{m.info_pointer()}</span>
<span className="text-xs">{mouseX},{mouseY}</span>
</div>
) : null}
{(debugMode && mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs font-semibold">{m.info_last_move()}</span>
<span className="text-xs">
{mouseMove ?
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
"N/A"}
{mouseMove ? `${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` : "N/A"}
</span>
</div>
) : null}
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs font-semibold">{m.info_usb_state()}</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs font-semibold">{m.info_hdmi_state()}</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HidRPC State:</span>
<span className="text-xs font-semibold">{m.info_hidrpc_state()}</span>
<span className="text-xs">{rpcHidStatus}</span>
</div>
)}
{isPasteInProgress && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">Paste Mode:</span>
<span className="text-xs">Enabled</span>
<span className="text-xs font-semibold">{m.info_paste_mode()}</span>
<span className="text-xs">{m.info_paste_enabled()}</span>
</div>
)}
{showPressedKeys && (
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{displayKeys}
</h2>
<span className="text-xs font-semibold">{m.info_keys()}</span>
<h2 className="text-xs">{displayKeys}</h2>
</div>
)}
</div>
</div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
{isTurnServerInUse && (
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
Relayed by Cloudflare
{m.info_relayed_by_cloudflare()}
</div>
)}
@ -140,8 +134,9 @@ export default function InfoBar() {
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Caps Lock
{m.info_caps_lock()}
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
@ -150,8 +145,9 @@ export default function InfoBar() {
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Num Lock
{m.info_num_lock()}
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
@ -160,22 +156,19 @@ export default function InfoBar() {
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
{m.info_scroll_lock()}
</div>
{keyboardLedState.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Compose
</div>
<div className="shrink-0 p-1 px-1.5 text-xs">{m.info_compose()}</div>
) : null}
{keyboardLedState.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Kana
</div>
<div className="shrink-0 p-1 px-1.5 text-xs">{m.info_kana()}</div>
) : null}
{keyboardLedState.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Shift
</div>
<div className="shrink-0 p-1 px-1.5 text-xs">{m.info_shift()}</div>
) : null}
</div>
</div>

View File

@ -1,7 +1,7 @@
import { NetworkState } from "../hooks/stores";
import { LifeTimeLabel } from "../routes/devices.$id.settings.network";
import { GridCard } from "./Card";
import { NetworkState } from "@hooks/stores";
import { GridCard } from "@components/Card";
import { LifeTimeLabel } from "@routes/devices.$id.settings.network";
import { m } from "@localizations/messages.js";
export default function Ipv6NetworkCard({
networkState,
@ -13,14 +13,14 @@ export default function Ipv6NetworkCard({
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
{m.ipv6_information()}
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.ipv6_link_local && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
{m.ipv6_link_local()}
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}
@ -42,7 +42,7 @@ export default function Ipv6NetworkCard({
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
{m.ipv6_address_label()}
</span>
<span className="text-sm font-medium">{addr.address}</span>
</div>
@ -50,12 +50,12 @@ export default function Ipv6NetworkCard({
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
{m.ipv6_valid_lifetime()}
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
{m.not_available()}
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
@ -66,12 +66,12 @@ export default function Ipv6NetworkCard({
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
{m.ipv6_preferred_lifetime()}
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
{m.not_available()}
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />

View File

@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { LuExternalLink } from "react-icons/lu";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { Button, LinkButton } from "@components/Button";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { InputFieldWithLabel } from "./InputField";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { m } from "@localizations/messages.js";
export interface JigglerConfig {
inactivity_limit_seconds: number;
@ -51,7 +51,7 @@ export function JigglerSetting({
const exampleConfigs = [
{
name: "Business Hours 9-17",
name: m.jiggler_example_business_hours_late(),
config: {
inactivity_limit_seconds: 60,
jitter_percentage: 25,
@ -60,7 +60,7 @@ export function JigglerSetting({
},
},
{
name: "Business Hours 8-17",
name: m.jiggler_example_business_hours_early(),
config: {
inactivity_limit_seconds: 60,
jitter_percentage: 25,
@ -69,13 +69,10 @@ export function JigglerSetting({
},
},
];
return (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Examples
</h4>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{m.jiggler_examples_label()}</h4>
<div className="flex flex-wrap gap-2">
{exampleConfigs.map((example, index) => (
<Button
@ -90,7 +87,7 @@ export function JigglerSetting({
to="https://crontab.guru/examples.html"
size="XS"
theme="light"
text="More examples"
text={m.jiggler_more_examples()}
LeadingIcon={LuExternalLink}
/>
</div>
@ -100,8 +97,8 @@ export function JigglerSetting({
<InputFieldWithLabel
required
size="SM"
label="Cron Schedule"
description="Cron expression for scheduling"
label={m.jiggler_cron_schedule_label()}
description={m.jiggler_cron_schedule_description()}
placeholder="*/20 * * * * *"
value={jigglerConfigState.schedule_cron_tab}
onChange={e =>
@ -114,8 +111,8 @@ export function JigglerSetting({
<InputFieldWithLabel
size="SM"
label="Inactivity Limit Seconds"
description="Inactivity time before jiggle"
label={m.jiggler_inactivity_limit_label()}
description={m.jiggler_inactivity_limit_description()}
value={jigglerConfigState.inactivity_limit_seconds}
type="number"
min="1"
@ -131,8 +128,8 @@ export function JigglerSetting({
<InputFieldWithLabel
required
size="SM"
label="Random delay"
description="To avoid recognizable patterns"
label={m.jiggler_random_delay_label()}
description={m.jiggler_random_delay_description()}
placeholder="25"
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
value={jigglerConfigState.jitter_percentage}
@ -149,8 +146,8 @@ export function JigglerSetting({
<SelectMenuBasic
size="SM"
label="Timezone"
description="Timezone for cron schedule"
label={m.jiggler_timezone_label()}
description={m.jiggler_timezone_description()}
value={jigglerConfigState.timezone || "UTC"}
disabled={timezones.length === 0}
onChange={e =>
@ -167,7 +164,7 @@ export function JigglerSetting({
<Button
size="SM"
theme="primary"
text="Save Jiggler Config"
text={m.jiggler_save_jiggler_config()}
onClick={() => onSave(jigglerConfigState)}
/>
</div>

View File

@ -1,10 +1,11 @@
import { Link } from "react-router";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router";
import { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card";
import { Button, LinkButton } from "@components/Button";
import { m } from "@localizations/messages.js";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed
@ -62,16 +63,16 @@ export default function KvmCard({
{online ? (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-green-600 bg-green-500" />
<div className="text-sm text-black dark:text-white">Online</div>
<div className="text-sm text-black dark:text-white">{m.online()}</div>
</div>
) : (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-slate-400/60 dark:border-slate-500 bg-slate-200 dark:bg-slate-600" />
<div className="text-sm text-black dark:text-white">
{lastSeen ? (
<>Last online {getRelativeTimeString(lastSeen)}</>
<>{m.last_online({ time: getRelativeTimeString(lastSeen) })}</>
) : (
<>Never seen online</>
<>{m.never_seen_online()}</>
)}
</div>
</div>
@ -85,7 +86,7 @@ export default function KvmCard({
<LinkButton
size="MD"
theme="light"
text="Connect to KVM"
text={m.connect_to_kvm()}
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
to={`/devices/${id}`}
@ -94,7 +95,7 @@ export default function KvmCard({
<Button
size="MD"
theme="light"
text="Troubleshoot Connection"
text={m.troubleshoot_connection()}
textAlign="center"
/>
)}

View File

@ -1,11 +1,11 @@
import { useEffect } from "react";
import { LuCommand } from "react-icons/lu";
import { useMacrosStore } from "@hooks/stores";
import useKeyboard from "@hooks/useKeyboard";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button";
import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();

View File

@ -1,18 +1,19 @@
import { useState } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "@/components/Button";
import FieldLabel from "@/components/FieldLabel";
import Fieldset from "@/components/Fieldset";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import { MacroStepCard } from "@/components/MacroStepCard";
import { KeySequence } from "@hooks/stores";
import useKeyboardLayout from "@hooks/useKeyboardLayout";
import { Button } from "@components/Button";
import FieldLabel from "@components/FieldLabel";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel, FieldError } from "@components/InputField";
import { MacroStepCard } from "@components/MacroStepCard";
import {
DEFAULT_DELAY,
MAX_STEPS_PER_MACRO,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
import { KeySequence } from "@/hooks/stores";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { m } from "@localizations/messages.js";
interface ValidationErrors {
name?: string;
@ -31,7 +32,6 @@ interface MacroFormProps {
onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
submitText?: string;
}
export function MacroForm({
@ -39,7 +39,6 @@ export function MacroForm({
onSubmit,
onCancel,
isSubmitting = false,
submitText = "Save Macro",
}: MacroFormProps) {
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
@ -57,13 +56,13 @@ export function MacroForm({
// Name validation
if (!macro.name?.trim()) {
newErrors.name = "Name is required";
newErrors.name = m.macro_name_required();
} else if (macro.name.trim().length > 50) {
newErrors.name = "Name must be less than 50 characters";
newErrors.name = m.macro_name_too_long();
}
if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } };
newErrors.steps = { 0: { keys: m.macro_at_least_one_step_required() } };
} else {
const hasKeyOrModifier = macro.steps.some(
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
@ -71,7 +70,7 @@ export function MacroForm({
if (!hasKeyOrModifier) {
newErrors.steps = {
0: { keys: "At least one step must have keys or modifiers" },
0: { keys: m.macro_at_least_one_step_keys_or_modifiers() },
};
}
}
@ -82,7 +81,7 @@ export function MacroForm({
const handleSubmit = async () => {
if (!validateForm()) {
showTemporaryError("Please fix the validation errors");
showTemporaryError(m.macro_please_fix_validation_errors());
return;
}
@ -92,7 +91,7 @@ export function MacroForm({
if (error instanceof Error) {
showTemporaryError(error.message);
} else {
showTemporaryError("An error occurred while saving");
showTemporaryError(m.macro_save_error());
}
}
};
@ -114,7 +113,7 @@ export function MacroForm({
? newSteps[stepIndex].keys
: [];
if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
showTemporaryError(m.macro_max_steps_error({max: MAX_KEYS_PER_STEP}));
return;
}
newSteps[stepIndex].keys = [...keysArray, option.value];
@ -178,8 +177,8 @@ export function MacroForm({
<Fieldset>
<InputFieldWithLabel
type="text"
label="Macro Name"
placeholder="Macro Name"
label={m.macro_name_label()}
placeholder={m.macro_name_label()}
value={macro.name}
error={errors.name}
onChange={e => {
@ -197,12 +196,12 @@ export function MacroForm({
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<FieldLabel
label="Steps"
description={`Keys/modifiers executed in sequence with a delay between each step.`}
label={m.macro_steps_label()}
description={m.macro_steps_description()}
/>
</div>
<span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
{m.macro_step_count({steps: macro.steps?.length || 0, max: MAX_STEPS_PER_MACRO})}
</span>
</div>
{errors.steps && errors.steps[0]?.keys && (
@ -248,12 +247,10 @@ export function MacroForm({
theme="light"
fullWidth
LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`}
text={m.macro_add_step({ maxed_out: isMaxStepsReached ? m.macro_max_steps_reached({ max: MAX_STEPS_PER_MACRO} ) : ""})}
onClick={() => {
if (isMaxStepsReached) {
showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
);
showTemporaryError(m.macro_max_steps_error({max: MAX_STEPS_PER_MACRO}));
return;
}
@ -280,11 +277,11 @@ export function MacroForm({
<Button
size="SM"
theme="primary"
text={isSubmitting ? "Saving..." : submitText}
text={isSubmitting ? m.saving() : m.macro_save()}
onClick={handleSubmit}
disabled={isSubmitting}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
<Button size="SM" theme="light" text={m.cancel()} onClick={onCancel} />
</div>
</div>
</div>

View File

@ -1,14 +1,15 @@
import { useMemo } from "react";
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import Card from "@/components/Card";
import FieldLabel from "@/components/FieldLabel";
import { Button } from "@components/Button";
import { Combobox } from "@components/Combobox";
import Card from "@components/Card";
import FieldLabel from "@components/FieldLabel";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import { KeyboardLayout } from "@/keyboardLayouts";
import { keys, modifiers } from "@/keyboardMappings";
import { m } from "@localizations/messages.js";
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
@ -25,6 +26,7 @@ const groupedModifiers: Record<string, typeof modifierOptions> = {
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
};
// not going to localize these since they're short time intervals
const basePresetDelays = [
{ value: "50", label: "50ms" },
{ value: "100", label: "100ms" },
@ -137,7 +139,7 @@ export function MacroStepCard({
size="XS"
theme="light"
className="text-red-500 dark:text-red-400"
text="Delete"
text={m.delete()}
LeadingIcon={LuTrash2}
onClick={onDelete}
/>
@ -147,7 +149,7 @@ export function MacroStepCard({
<div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" />
<FieldLabel label={m.macro_step_modifiers_label()} description={m.macro_step_modifiers_description()}/>
<div className="inline-flex flex-wrap gap-3">
{Object.entries(groupedModifiers).map(([group, mods]) => (
<div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
@ -179,7 +181,7 @@ export function MacroStepCard({
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
<FieldLabel label={m.macro_step_keys_label()} description={m.macro_step_keys_description({max: MAX_KEYS_PER_STEP})} />
</div>
{ensureArray(step.keys) && step.keys.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2">
@ -214,19 +216,19 @@ export function MacroStepCard({
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={() => filteredKeys}
disabledMessage="Max keys reached"
disabledMessage={m.macro_step_max_keys_reached({max: MAX_KEYS_PER_STEP})}
size="SM"
immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
emptyMessage="No matching keys found"
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? m.macro_step_max_keys_reached() : m.macro_step_search_for_key()}
emptyMessage={m.macro_step_no_matching_keys_found()}
/>
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
<FieldLabel label={m.macro_step_duration_label()} description={m.macro_step_duration_description()} />
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic

View File

@ -2,10 +2,10 @@
import { ComponentProps } from "react";
import { cva, cx } from "cva";
import { someIterable } from "../utils";
import { GridCard } from "./Card";
import MetricsChart from "./MetricsChart";
import { GridCard } from "@components/Card";
import MetricsChart from "@components/MetricsChart";
import { someIterable } from "@/utils";
import { m } from "@localizations/messages.js";
interface ChartPoint {
date: number;
@ -159,7 +159,7 @@ export function Metric<T, K extends keyof T>({
>
{!ready ? (
<div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p>
<p className="text-slate-700">{m.metric_waiting_for_data()}</p>
</div>
) : supportedFinal ? (
<MetricsChart
@ -170,7 +170,7 @@ export function Metric<T, K extends keyof T>({
/>
) : (
<div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p>
<p className="text-black">{m.metric_not_supported()}</p>
</div>
)}
</div>

View File

@ -51,6 +51,7 @@ export default function MetricsChart({
axisLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickFormatter={date => {
// TODO use locale from user settings for date formatting
return new Date(date * 1000).toLocaleString("en-US", {
hourCycle: "h23",
hour: "numeric",

View File

@ -1,6 +1,7 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard";
import EmptyCard from "@components/EmptyCard";
import { m } from "@localizations/messages.js";
export default function NotFoundPage() {
return (
@ -9,8 +10,8 @@ export default function NotFoundPage() {
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Not found"
description="The page you were looking for does not exist."
headline={m.not_found()}
description={m.page_not_found_description()}
/>
</div>
</div>

View File

@ -1,14 +1,15 @@
import StatusCard from "@components/StatusCards";
import { m } from "@localizations/messages.js";
const PeerConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Connection error",
closing: "Closing",
failed: "Connection failed",
closed: "Closed",
new: "Connecting",
connected: m.peer_connection_connected(),
connecting: m.peer_connection_connecting(),
disconnected: m.peer_connection_disconnected(),
error: m.peer_connection_error(),
closing: m.peer_connection_closing(),
failed: m.peer_connection_failed(),
closed: m.peer_connection_closed(),
new: m.peer_connection_new(),
} as Record<RTCPeerConnectionState | "error" | "closing", string>;
export type PeerConnections = keyof typeof PeerConnectionStatusMap;

View File

@ -1,12 +1,10 @@
import React, { JSX } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import Card from "@components/Card";
import FieldLabel from "@components/FieldLabel";
import { cva } from "@/cva.config";
import Card from "./Card";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"

View File

@ -1,6 +1,7 @@
import { AvailableSidebarViews } from "@hooks/stores";
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores";
import { m } from "@localizations/messages.js";
export default function SidebarHeader({
title,
@ -17,7 +18,7 @@ export default function SidebarHeader({
<Button
size="XS"
theme="blank"
text="Hide"
text={m.hide()}
LeadingIcon={({ className }) => (
<svg
className={cx(className, "rotate-180")}

View File

@ -1,9 +1,9 @@
import { Link } from "react-router";
import React from "react";
import { Link } from "react-router";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import LogoBlueIcon from "@assets/logo-blue.png";
import LogoWhiteIcon from "@assets/logo-white.svg";
import Container from "@components/Container";
interface Props { logoHref?: string; actionElement?: React.ReactNode }

View File

@ -1,7 +1,8 @@
import { CheckIcon } from "@heroicons/react/16/solid";
import Card from "@components/Card";
import { m } from "@localizations/messages.js";
import { cva, cx } from "@/cva.config";
import Card from "@/components/Card";
interface Props {
nSteps: number;
@ -49,7 +50,7 @@ export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props)
)}
key={`${i}-${currStepIdx}`}
>
Step {i + 1}
{m.step_counter_step({ step: i + 1 })}
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect, useMemo } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
@ -9,9 +9,9 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
import { AvailableTerminalTypes, useUiStore } from "@hooks/stores";
import { Button } from "@components/Button";
import { m } from "@localizations/messages.js";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
@ -54,6 +54,7 @@ const TERMINAL_CONFIG = {
// Add these configurations:
cursorStyle: "block",
rendererType: "canvas", // Ensure we're using the canvas renderer
unicode: { activeVersion: "11" }
} as const;
function Terminal({
@ -144,7 +145,6 @@ function Terminal({
instance.loadAddon(new ClipboardAddon());
instance.loadAddon(new Unicode11Addon());
instance.loadAddon(new WebLinksAddon());
instance.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
@ -191,7 +191,7 @@ function Terminal({
<Button
size="XS"
theme="light"
text="Hide"
text={m.hide()}
LeadingIcon={ChevronDownIcon}
onClick={() => setTerminalType("none")}
/>

View File

@ -1,9 +1,9 @@
import React, { JSX } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { FieldError } from "@/components/InputField";
import Card from "@/components/Card";
import FieldLabel from "@components/FieldLabel";
import { FieldError } from "@components/InputField";
import Card from "@components/Card";
import { cx } from "@/cva.config";
type TextAreaProps = JSX.IntrinsicElements["textarea"] & {

View File

@ -1,10 +1,11 @@
import React from "react";
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import KeyboardAndMouseConnectedIcon from "@assets/keyboard-and-mouse-connected.png";
import { USBStates } from "@hooks/stores";
import { m } from "@localizations/messages.js";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { USBStates } from "@/hooks/stores";
type StatusProps = Record<
USBStates,
@ -16,11 +17,11 @@ type StatusProps = Record<
>;
const USBStateMap: Record<USBStates, string> = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
suspended: "Low power mode",
configured: m.usb_state_connected(),
attached: m.usb_state_connecting(),
addressed: m.usb_state_connecting(),
"not attached": m.usb_state_disconnected(),
suspended: m.usb_state_low_power_mode(),
};
const StatusCardProps: StatusProps = {
configured: {
@ -80,8 +81,8 @@ export default function USBStateStatus({
return (
<StatusCard
title="USB"
status="Disconnected"
title={m.usb()}
status={m.usb_state_disconnected()}
icon={Icon}
iconClassName={iconClassName}
statusIndicatorClassName={statusIndicatorClassName}
@ -90,6 +91,6 @@ export default function USBStateStatus({
}
return (
<StatusCard title="USB" status={USBStateMap[state]} {...StatusCardProps[state]} />
<StatusCard title={m.usb()} status={USBStateMap[state]} {...StatusCardProps[state]} />
);
}

View File

@ -1,10 +1,9 @@
import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import LoadingSpinner from "@components/LoadingSpinner";
import { m } from "@localizations/messages.js";
export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation();
@ -17,12 +16,12 @@ export default function UpdateInProgressStatusCard() {
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1">
<div className="text-ellipsis text-sm font-semibold leading-none transition">
Update in Progress
{m.update_in_progress()}
</div>
<div className="text-sm leading-none">
<div className="flex items-center gap-x-1">
<span className={cx("transition")}>
Please don{"'"}t turn off your device...
{m.updating_leave_device_on()}
</span>
</div>
</div>
@ -32,7 +31,7 @@ export default function UpdateInProgressStatusCard() {
size="SM"
className="pointer-events-auto"
theme="light"
text="View Details"
text={m.view_details()}
onClick={() => navigateTo("/settings/general/update")}
/>
</div>

View File

@ -1,15 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { m } from "@localizations/messages.js";
import { SettingsItem } from "@components/SettingsItem";
import Checkbox from "@components/Checkbox";
import { Button } from "@components/Button";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import Fieldset from "@components/Fieldset";
import notifications from "@/notifications";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import Checkbox from "./Checkbox";
import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
export interface USBConfig {
vendor_id: string;
product_id: string;
@ -34,7 +34,7 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
const usbPresets = [
{
label: "Keyboard, Mouse and Mass Storage",
label: m.usb_device_keyboard_mouse_and_mass_storage(),
value: "default",
config: {
keyboard: true,
@ -44,7 +44,7 @@ const usbPresets = [
},
},
{
label: "Keyboard Only",
label: m.usb_device_keyboard_only(),
value: "keyboard_only",
config: {
keyboard: true,
@ -54,7 +54,7 @@ const usbPresets = [
},
},
{
label: "Custom",
label: m.usb_device_custom(),
value: "custom",
},
];
@ -72,7 +72,7 @@ export function UsbDeviceSetting() {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
notifications.error(
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`,
m.usb_device_failed_load({ error: String(resp.error.data || "Unknown error") }),
);
} else {
const usbConfigState = resp.result as UsbDeviceConfig;
@ -101,7 +101,7 @@ export function UsbDeviceSetting() {
send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
m.usb_device_failed_set({ error: String(resp.error.data || "Unknown error") }),
);
setLoading(false);
return;
@ -111,7 +111,7 @@ export function UsbDeviceSetting() {
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncUsbDeviceConfig();
notifications.success(`USB Devices updated`);
notifications.success(m.usb_device_updated());
});
},
[send, syncUsbDeviceConfig],
@ -154,14 +154,14 @@ export function UsbDeviceSetting() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader
title="USB Device"
description="USB devices to emulate on the target computer"
title={m.usb_device_title()}
description={m.usb_device_description()}
/>
<SettingsItem
loading={loading}
title="Classes"
description="USB device classes in the composite device"
title={m.usb_device_classes_title()}
description={m.usb_device_classes_description()}
>
<SelectMenuBasic
size="SM"
@ -178,7 +178,7 @@ export function UsbDeviceSetting() {
<div className="ml-2 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem title="Enable Keyboard" description="Enable Keyboard">
<SettingsItem title={m.usb_device_enable_keyboard_title()} description={m.usb_device_enable_keyboard_description()}>
<Checkbox
checked={usbDeviceConfig.keyboard}
onChange={onUsbConfigItemChange("keyboard")}
@ -187,8 +187,8 @@ export function UsbDeviceSetting() {
</div>
<div className="space-y-4">
<SettingsItem
title="Enable Absolute Mouse (Pointer)"
description="Enable Absolute Mouse (Pointer)"
title={m.usb_device_enable_absolute_mouse_title()}
description={m.usb_device_enable_absolute_mouse_description()}
>
<Checkbox
checked={usbDeviceConfig.absolute_mouse}
@ -198,8 +198,8 @@ export function UsbDeviceSetting() {
</div>
<div className="space-y-4">
<SettingsItem
title="Enable Relative Mouse"
description="Enable Relative Mouse"
title={m.usb_device_enable_relative_mouse_title()}
description={m.usb_device_enable_relative_mouse_description()}
>
<Checkbox
checked={usbDeviceConfig.relative_mouse}
@ -209,8 +209,8 @@ export function UsbDeviceSetting() {
</div>
<div className="space-y-4">
<SettingsItem
title="Enable USB Mass Storage"
description="Sometimes it might need to be disabled to prevent issues with certain devices"
title={m.usb_device_enable_mass_storage_title()}
description={m.usb_device_enable_mass_storage_description()}
>
<Checkbox
checked={usbDeviceConfig.mass_storage}
@ -224,13 +224,13 @@ export function UsbDeviceSetting() {
size="SM"
loading={loading}
theme="primary"
text="Update USB Classes"
text={m.usb_device_update_classes()}
onClick={() => handleUsbConfigChange(usbDeviceConfig)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
text={m.usb_device_restore_default()}
onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)}
/>
</div>

View File

@ -1,15 +1,14 @@
import { useMemo , useCallback , useEffect, useState } from "react";
import { UsbConfigState } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { UsbConfigState } from "../hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { InputFieldWithLabel } from "./InputField";
import { SelectMenuBasic } from "./SelectMenuBasic";
import Fieldset from "./Fieldset";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
@ -31,21 +30,22 @@ export interface USBConfig {
product: string;
}
const usbConfigs = [
{
label: "JetKVM Default",
label: m.usb_config_default(),
value: "USB Emulation Device",
},
{
label: "Logitech Universal Adapter",
label: m.usb_config_logitech(),
value: "Logitech USB Input Device",
},
{
label: "Microsoft Wireless MultiMedia Keyboard",
label: m.usb_config_microsoft(),
value: "Wireless MultiMedia Keyboard",
},
{
label: "Dell Multimedia Pro Keyboard",
label: m.usb_config_dell(),
value: "Multimedia Pro Keyboard",
},
];
@ -97,7 +97,7 @@ export function UsbInfoSetting() {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
notifications.error(
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
m.usb_config_failed_load({ error: String(resp.error.data || "Unknown error") }),
);
} else {
const usbConfigState = resp.result as UsbConfigState;
@ -116,7 +116,7 @@ export function UsbInfoSetting() {
send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
m.usb_config_failed_set({ error: String(resp.error.data || "Unknown error") }),
);
setLoading(false);
return;
@ -126,7 +126,7 @@ export function UsbInfoSetting() {
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
notifications.success(
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
m.usb_config_set_success({ manufacturer: usbConfig.manufacturer, product: usbConfig.product }),
);
syncUsbConfigProduct();
@ -152,8 +152,8 @@ export function UsbInfoSetting() {
<Fieldset disabled={loading} className="space-y-4">
<SettingsItem
loading={loading}
title="Identifiers"
description="USB device identifiers exposed to the target computer"
title={m.usb_config_identifiers_title()}
description={m.usb_config_identifiers_description()}
>
<SelectMenuBasic
size="SM"
@ -169,7 +169,7 @@ export function UsbInfoSetting() {
handleUsbConfigChange(usbConfig);
}
}}
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
options={[...usbConfigs, { value: "custom", label: m.usb_config_custom() }]}
/>
</SettingsItem>
{usbConfigProduct === "custom" && (
@ -246,38 +246,38 @@ function USBConfigDialog({
<div className="grid grid-cols-2 gap-4">
<InputFieldWithLabel
required
label="Vendor ID"
placeholder="Enter Vendor ID"
label={m.usb_config_vendor_id_label()}
placeholder={m.usb_config_vendor_id_placeholder()}
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.vendor_id}
onChange={e => handleUsbVendorIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product ID"
placeholder="Enter Product ID"
label={m.usb_config_product_id_label()}
placeholder={m.usb_config_product_id_placeholder()}
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.product_id}
onChange={e => handleUsbProductIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Serial Number"
placeholder="Enter Serial Number"
label={m.usb_config_serial_number_label()}
placeholder={m.usb_config_serial_number_placeholder()}
defaultValue={usbConfigState?.serial_number}
onChange={e => handleUsbSerialChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Manufacturer"
placeholder="Enter Manufacturer"
label={m.usb_config_manufacturer_label()}
placeholder={m.usb_config_manufacturer_placeholder()}
defaultValue={usbConfigState?.manufacturer}
onChange={e => handleUsbManufacturer(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product Name"
placeholder="Enter Product Name"
label={m.usb_config_product_name_label()}
placeholder={m.usb_config_product_name_placeholder()}
defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)}
/>
@ -287,13 +287,13 @@ function USBConfigDialog({
loading={loading}
size="SM"
theme="primary"
text="Update USB Identifiers"
text={m.usb_config_update_identifiers()}
onClick={() => onSetUsbConfig(usbConfigState)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
text={m.usb_config_restore_default()}
onClick={onRestoreToDefault}
/>
</div>

View File

@ -5,6 +5,7 @@ import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu";
import { BsMouseFill } from "react-icons/bs";
import { m } from "@localizations/messages.js";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card";
@ -46,7 +47,7 @@ export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream...
{m.video_overlay_loading_stream()}
</p>
</div>
</OverlayContent>
@ -118,26 +119,26 @@ export function ConnectionFailedOverlay({
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<h2 className="text-xl font-bold">{m.video_overlay_connection_issue_title()}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
<li>{m.video_overlay_conn_verify_power()}</li>
<li>{m.video_overlay_conn_check_cables()}</li>
<li>{m.video_overlay_conn_ensure_network()}</li>
<li>{m.video_overlay_conn_restart()}</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="primary"
text="Troubleshooting Guide"
text={m.video_overlay_troubleshooting_guide()}
TrailingIcon={ArrowRightIcon}
size="SM"
/>
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
text={m.video_overlay_try_again()}
size="SM"
theme="light"
/>
@ -178,12 +179,12 @@ export function PeerConnectionDisconnectedOverlay({
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<h2 className="text-xl font-bold">{m.video_overlay_connection_issue_title()}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
<li>{m.video_overlay_conn_verify_power()}</li>
<li>{m.video_overlay_conn_check_cables()}</li>
<li>{m.video_overlay_conn_ensure_network()}</li>
<li>{m.video_overlay_conn_restart()}</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
@ -191,7 +192,7 @@ export function PeerConnectionDisconnectedOverlay({
<div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection...
{m.video_overlay_retrying_connection()}
</p>
</div>
</Card>
@ -235,23 +236,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<h2 className="text-xl font-bold">{m.video_overlay_no_hdmi_signal()}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>
Ensure source device is powered on and outputting a signal
</li>
<li>
If using an adapter, ensure it&apos;s compatible and functioning
correctly
</li>
<li>{m.video_overlay_no_hdmi_ensure_cable()}</li>
<li>{m.video_overlay_no_hdmi_ensure_power()}</li>
<li>{m.video_overlay_no_hdmi_adapter_compat()}</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
text={m.video_overlay_learn_more()}
TrailingIcon={ArrowRightIcon}
size="SM"
/>
@ -282,18 +278,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<h2 className="text-xl font-bold">{m.video_overlay_hdmi_error_title()}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
<li>{m.video_overlay_hdmi_loose_faulty()}</li>
<li>{m.video_overlay_hdmi_incompatible_resolution()}</li>
<li>{m.video_overlay_hdmi_source_issue()}</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
text={m.video_overlay_learn_more()}
TrailingIcon={ArrowRightIcon}
size="SM"
/>
@ -334,7 +330,7 @@ export function NoAutoplayPermissionsOverlay({
<OverlayContent>
<div className="space-y-4">
<h2 className="text-2xl font-extrabold text-black dark:text-white">
Autoplay permissions required
{m.video_overlay_autoplay_permissions_required()}
</h2>
<div className="space-y-2 text-center">
@ -343,13 +339,13 @@ export function NoAutoplayPermissionsOverlay({
size="MD"
theme="primary"
LeadingIcon={LuPlay}
text="Manually start stream"
text={m.video_overlay_manually_start_stream()}
onClick={onPlayClick}
/>
</div>
<div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay
{m.video_overlay_enable_autoplay_settings()}
</div>
</div>
</div>
@ -381,7 +377,7 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
<div className="flex items-center space-x-2">
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
<span className="text-sm text-black dark:text-white">
Click on the video to enable mouse control
{m.video_overlay_pointerlock_click_to_enable()}
</span>
</div>
</div>

View File

@ -1,21 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { LuKeyboard } from "react-icons/lu";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button, LinkButton } from "@components/Button";
import "react-simple-keyboard/build/css/index.css";
import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config";
import { useHidStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { useHidStore, useUiStore } from "@hooks/stores";
import useKeyboard from "@hooks/useKeyboard";
import useKeyboardLayout from "@hooks/useKeyboardLayout";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { decodeModifiers, keys, latchingKeys, modifiers } from "@/keyboardMappings";
import { m } from "@localizations/messages.js";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@ -244,20 +242,20 @@ function KeyboardWrapper() {
<Button
size="XS"
theme="light"
text="Detach"
text={m.detach()}
onClick={() => setAttachedVirtualKeyboardVisibility(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
text={m.attach()}
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
/>
)}
</div>
<h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300">
Virtual Keyboard
{m.virtual_keyboard_header()}
</h2>
<div className="absolute right-2 flex items-center gap-x-2">
<div className="hidden md:flex gap-x-2 items-center">
@ -274,7 +272,7 @@ function KeyboardWrapper() {
<Button
size="XS"
theme="light"
text="Hide"
text={m.hide()}
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboardEnabled(false)}
/>

View File

@ -1,27 +1,27 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import MacroBar from "@/components/MacroBar";
import InfoBar from "@components/InfoBar";
import notifications from "@/notifications";
import useKeyboard from "@/hooks/useKeyboard";
import { cx } from "@/cva.config";
import { keys } from "@/keyboardMappings";
import useKeyboard from "@hooks/useKeyboard";
import useMouse from "@hooks/useMouse";
import {
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import useMouse from "@/hooks/useMouse";
} from "@hooks/stores";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import MacroBar from "@components/MacroBar";
import InfoBar from "@components/InfoBar";
import {
HDMIErrorOverlay,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay,
PointerLockBar,
} from "./VideoOverlay";
} from "@components/VideoOverlay";
import { keys } from "@/keyboardMappings";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export default function WebRTCVideo() {
// Video and stream related refs and states
@ -168,10 +168,10 @@ export default function WebRTCVideo() {
const handlePointerLockChange = () => {
if (document.pointerLockElement) {
notifications.success("Pointer lock Enabled, press escape to unlock");
notifications.success(m.video_pointer_lock_enabled());
setIsPointerLockActive(true);
} else {
notifications.success("Pointer lock Disabled");
notifications.success(m.video_pointer_lock_disabled());
setIsPointerLockActive(false);
}
};
@ -234,6 +234,18 @@ export default function WebRTCVideo() {
[getMouseWheelHandler],
);
function getAdjustedKeyCode(e: KeyboardEvent) {
const key = e.key;
let code = e.code;
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
return code;
}
const keyDownHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
@ -468,17 +480,6 @@ export default function WebRTCVideo() {
};
}, [videoSaturation, videoBrightness, videoContrast]);
function getAdjustedKeyCode(e: KeyboardEvent) {
const key = e.key;
let code = e.code;
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
return code;
}
return (
<div className="grid h-full w-full grid-rows-(--grid-layout)">

View File

@ -1,13 +1,13 @@
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { m } from "@localizations/messages.js";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button";
import Card from "@components/Card";
import LoadingSpinner from "@components/LoadingSpinner";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
@ -33,9 +33,7 @@ export function ATXPowerControl() {
useEffect(() => {
send("getATXState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.atx_power_control_get_state_error({ error: resp.error.data || m.unknown_error() }));
return;
}
setAtxState(resp.result as ATXState);
@ -56,9 +54,7 @@ export function ATXPowerControl() {
console.log("Sending long press ATX power action");
send("setATXPowerAction", { action: "power-long" }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.atx_power_control_send_action_error({ action: m.atx_power_control_long_power_button(), error: resp.error.data || m.unknown_error() }));
}
setIsPowerPressed(false);
});
@ -77,9 +73,7 @@ export function ATXPowerControl() {
console.log("Sending short press ATX power action");
send("setATXPowerAction", { action: "power-short" }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.atx_power_control_send_action_error({ action: m.atx_power_control_short_power_button(), error: resp.error.data || m.unknown_error() }));
}
});
}
@ -98,8 +92,8 @@ export function ATXPowerControl() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="ATX Power Control"
description="Control your ATX power settings"
title={m.extensions_atx_power_control()}
description={m.extensions_atx_power_control_description()}
/>
{atxState === null ? (
@ -115,7 +109,7 @@ export function ATXPowerControl() {
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power"
text={m.atx_power_control_power_button()}
onMouseDown={() => handlePowerPress(true)}
onMouseUp={() => handlePowerPress(false)}
onMouseLeave={() => handlePowerPress(false)}
@ -125,13 +119,11 @@ export function ATXPowerControl() {
size="SM"
theme="light"
LeadingIcon={LuRotateCcw}
text="Reset"
text={m.atx_power_control_reset_button()}
onClick={() => {
send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.atx_power_control_send_action_error({ action: m.atx_power_control_reset_button(), error: resp.error.data || m.unknown_error() }));
return;
}
});
@ -150,7 +142,7 @@ export function ATXPowerControl() {
atxState?.power ? "text-green-600" : "text-slate-300"
}`}
/>
Power LED
{m.atx_power_control_power_led()}
</span>
</div>
<div className="flex items-center space-x-2">
@ -161,7 +153,7 @@ export function ATXPowerControl() {
atxState?.hdd ? "text-blue-400" : "text-slate-300"
}`}
/>
HDD LED
{m.atx_power_control_hdd_led()}
</span>
</div>
</div>

View File

@ -1,14 +1,15 @@
import { LuPower } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react";
import { LuPower } from "react-icons/lu";
import { m } from "@localizations/messages.js";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner";
import {SelectMenuBasic} from "@components/SelectMenuBasic";
import notifications from "@/notifications";
interface DCPowerState {
isOn: boolean;
@ -25,9 +26,7 @@ export function DCPowerControl() {
const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.dc_power_control_get_state_error({ error: resp.error.data || m.unknown_error() }));
return;
}
setPowerState(resp.result as DCPowerState);
@ -37,9 +36,7 @@ export function DCPowerControl() {
const handlePowerToggle = (enabled: boolean) => {
send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.dc_power_control_set_power_state_error({ enabled: enabled, error: resp.error.data || m.unknown_error() }));
return;
}
getDCPowerState(); // Refresh state after change
@ -49,17 +46,13 @@ export function DCPowerControl() {
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.dc_power_control_set_restore_state_error({ state: state, error: resp.error.data || m.unknown_error() }));
return;
}
getDCPowerState(); // Refresh state after change
});
};
useEffect(() => {
getDCPowerState();
// Set up polling interval to update status
@ -70,8 +63,8 @@ export function DCPowerControl() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="DC Power Control"
description="Control your DC power settings"
title={m.extensions_dc_power_control()}
description={m.extensions_dc_power_control_description()}
/>
{powerState === null ? (
@ -87,7 +80,7 @@ export function DCPowerControl() {
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power On"
text={m.dc_power_control_power_on_button()}
onClick={() => handlePowerToggle(true)}
disabled={powerState.isOn}
/>
@ -95,7 +88,7 @@ export function DCPowerControl() {
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power Off"
text={m.dc_power_control_power_off_button()}
disabled={!powerState.isOn}
onClick={() => handlePowerToggle(false)}
/>
@ -104,13 +97,13 @@ export function DCPowerControl() {
<div className="flex items-center">
<SelectMenuBasic
size="SM"
label="Restore Power Loss"
label={m.dc_power_control_restore_power_state()}
value={powerState.restoreState}
onChange={e => handleRestoreChange(parseInt(e.target.value))}
options={[
{ value: '0', label: "Power OFF" },
{ value: '1', label: "Power ON" },
{ value: '2', label: "Last State" },
{ value: '0', label: m.dc_power_control_power_off_state()},
{ value: '1', label: m.dc_power_control_power_on_state()},
{ value: '2', label: m.dc_power_control_restore_last_state()},
]}
/>
</div>
@ -120,21 +113,21 @@ export function DCPowerControl() {
{/* Status Display */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<FieldLabel label="Voltage" />
<FieldLabel label={m.dc_power_control_voltage()} />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.voltage.toFixed(1)}V
{powerState.voltage.toFixed(1)}&nbsp;{m.dc_power_control_voltage_unit()}
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Current" />
<FieldLabel label={m.dc_power_control_current()} />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.current.toFixed(1)}A
{powerState.current.toFixed(1)}&nbsp;{m.dc_power_control_current_unit()}
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Power" />
<FieldLabel label={m.dc_power_control_power()}/>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.power.toFixed(1)}W
{powerState.power.toFixed(1)}&nbsp;{m.dc_power_control_power_unit()}
</p>
</div>
</div>

View File

@ -1,13 +1,14 @@
import { LuTerminal } from "react-icons/lu";
import { useEffect, useState } from "react";
import { LuTerminal } from "react-icons/lu";
import { m } from "@localizations/messages.js";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useUiStore } from "@hooks/stores";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
interface SerialSettings {
baudRate: string;
@ -28,9 +29,7 @@ export function SerialConsole() {
useEffect(() => {
send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.serial_console_get_settings_error({ error: resp.error.data || m.unknown_error() }));
return;
}
setSettings(resp.result as SerialSettings);
@ -41,9 +40,7 @@ export function SerialConsole() {
const newSettings = { ...settings, [setting]: value };
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
);
notifications.error(m.serial_console_set_settings_error({ settings: setting, error: resp.error.data || m.unknown_error() }));
return;
}
setSettings(newSettings);
@ -54,8 +51,8 @@ export function SerialConsole() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Serial Console"
description="Configure your serial console settings"
title={m.extension_serial_console()}
description={m.serial_console_configure_description()}
/>
<Card className="animate-fadeIn opacity-0">
@ -66,10 +63,10 @@ export function SerialConsole() {
size="SM"
theme="primary"
LeadingIcon={LuTerminal}
text="Open Console"
text={m.serial_console_open_console()}
onClick={() => {
setTerminalType("serial");
console.log("Opening serial console with settings: ", settings);
setTerminalType("serial");
}}
/>
</div>
@ -77,7 +74,7 @@ export function SerialConsole() {
{/* Settings */}
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
label={m.serial_console_baud_rate()}
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
@ -93,7 +90,7 @@ export function SerialConsole() {
/>
<SelectMenuBasic
label="Data Bits"
label={m.serial_console_data_bits()}
options={[
{ label: "8", value: "8" },
{ label: "7", value: "7" },
@ -103,7 +100,7 @@ export function SerialConsole() {
/>
<SelectMenuBasic
label="Stop Bits"
label={m.serial_console_stop_bits()}
options={[
{ label: "1", value: "1" },
{ label: "1.5", value: "1.5" },
@ -114,11 +111,13 @@ export function SerialConsole() {
/>
<SelectMenuBasic
label="Parity"
label={m.serial_console_parity()}
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },
{ label: "Odd", value: "odd" },
{ label: m.serial_console_parity_none(), value: "none" },
{ label: m.serial_console_parity_even(), value: "even" },
{ label: m.serial_console_parity_odd(), value: "odd" },
{ label: m.serial_console_parity_mark(), value: "mark" },
{ label: m.serial_console_parity_space(), value: "space" },
]}
value={settings.parity}
onChange={e => handleSettingChange("parity", e.target.value)}

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { m } from "@localizations/messages.js";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
@ -20,20 +21,20 @@ interface Extension {
const AVAILABLE_EXTENSIONS: Extension[] = [
{
id: "atx-power",
name: "ATX Power Control",
description: "Control your ATX Power extension",
name: m.extensions_atx_power_control(),
description: m.extensions_atx_power_control_description(),
icon: LuPower,
},
{
id: "dc-power",
name: "DC Power Control",
description: "Control your DC Power extension",
name: m.extensions_dc_power_control(),
description: m.extensions_dc_power_control(),
icon: LuPlugZap,
},
{
id: "serial-console",
name: "Serial Console",
description: "Access your serial console extension",
name: m.extension_serial_console(),
description: m.extension_serial_console_description(),
icon: LuTerminal,
},
];
@ -60,7 +61,7 @@ export default function ExtensionPopover() {
send("setActiveExtension", { extensionId: extension?.id || "" }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
m.extension_popover_set_error_notification({ error: resp.error.data || m.unknown_error() }),
);
return;
}
@ -101,7 +102,7 @@ export default function ExtensionPopover() {
<Button
size="SM"
theme="light"
text="Unload Extension"
text={m.extension_popover_unload_extension()}
onClick={() => handleSetActiveExtension(null)}
/>
</div>
@ -110,8 +111,8 @@ export default function ExtensionPopover() {
// Extensions List View
<div className="space-y-4">
<SettingsPageHeader
title="Extensions"
description="Load and manage your extensions"
title={m.extensions_popover_extensions()}
description={m.extension_popover_load_and_manage_extensions()}
/>
<Card className="animate-fadeIn opacity-0" >
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
@ -131,7 +132,7 @@ export default function ExtensionPopover() {
<Button
size="XS"
theme="light"
text="Load"
text={m.load()}
onClick={() => handleSetActiveExtension(extension)}
/>
</div>

View File

@ -1,20 +1,17 @@
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { forwardRef, useEffect, useCallback } from "react";
import {
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { LuLink, LuPlus, LuRadioReceiver } from "react-icons/lu";
import { useClose } from "@headlessui/react";
import { useLocation } from "react-router";
import { m } from "@localizations/messages.js";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores";
import { RemoteVirtualMediaState, useMountMediaStore } from "@hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
@ -25,9 +22,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
);
notifications.error(m.mount_get_state_error({ error: response.error.message }));
} else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
}
@ -37,7 +32,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const handleUnmount = () => {
send("unmountImage", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
notifications.error(m.mount_unmount_error({ error: response.error.message }));
} else {
syncRemoteVirtualMediaState();
}
@ -57,10 +52,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
{m.mount_no_mounted_media()}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
{m.mount_add_file_to_get_started()}
</p>
</div>
</div>
@ -81,7 +76,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">
Streaming from URL
{m.mount_streaming_from_url()}
</h3>
<p className="truncate text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(url, 55)}
@ -105,7 +100,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">
Mounted from JetKVM Storage
{m.mount_mounted_from_storage()}
</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)}
@ -138,8 +133,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="h-full space-y-4">
<div className="space-y-4">
<SettingsPageHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
title={m.mount_virtual_media()}
description={m.mount_virtual_media_description()}
/>
<div
@ -163,9 +158,9 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
{remoteVirtualMediaState ? (
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span>{m.mount_mounted_as()}</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
{remoteVirtualMediaState.mode === "Disk" ? m.mount_mode_disk() : m.mount_mode_cdrom()}
</span>
</div>
@ -173,7 +168,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="blank"
text="Close"
text={m.close()}
onClick={() => {
close();
}}
@ -181,7 +176,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="light"
text="Unmount"
text={m.mount_unmount()}
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
@ -227,7 +222,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="blank"
text="Close"
text={m.close()}
onClick={() => {
close();
}}
@ -235,7 +230,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="primary"
text="Add New Media"
text={m.mount_add_new_media()}
onClick={() => {
setModalView("mode");
navigateTo("/mount");

View File

@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClose } from "@headlessui/react";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { m } from "@localizations/messages.js";
import { useHidStore, useSettingsStore, useUiStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import useKeyboard, { type MacroStep } from "@hooks/useKeyboard";
import useKeyboardLayout from "@hooks/useKeyboardLayout";
import notifications from "@/notifications";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
@ -105,7 +106,7 @@ export default function PasteModal() {
}
} catch (error) {
console.error("Failed to paste text:", error);
notifications.error("Failed to paste text");
notifications.error(m.paste_modal_failed_paste({ error: String(error) }));
}
}, [selectedKeyboard, executeMacro, delay]);
@ -122,8 +123,8 @@ export default function PasteModal() {
<div className="h-full space-y-4">
<div className="space-y-4">
<SettingsPageHeader
title="Paste text"
description="Paste text from your client to the remote host"
title={m.paste_modal_paste_text()}
description={m.paste_modal_paste_text_description()}
/>
<div
@ -143,7 +144,7 @@ export default function PasteModal() {
>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
label={m.paste_modal_paste_from_host()}
rows={4}
onKeyUp={e => e.stopPropagation()}
maxLength={pasteMaxLength}
@ -176,7 +177,7 @@ export default function PasteModal() {
<div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{m.paste_modal_invalid_chars_intro()}{" "}
{invalidChars.join(", ")}
</span>
</div>
@ -186,8 +187,8 @@ export default function PasteModal() {
<div className={cx("text-xs text-slate-600 dark:text-slate-400", delayClassName)}>
<InputFieldWithLabel
type="number"
label="Delay between keys"
placeholder="Delay between keys"
label={m.paste_modal_delay_between_keys()}
placeholder={m.paste_modal_delay_between_keys()}
min={50}
max={65534}
value={delayValue}
@ -199,15 +200,14 @@ export default function PasteModal() {
<div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
Delay must be between 50 and 65534
{m.paste_modal_delay_out_of_range({ min: 50, max: 65534 })}
</span>
</div>
)}
</div>
<div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {selectedKeyboard.isoCode}-
{selectedKeyboard.name}
{m.paste_modal_sending_using_layout({ iso: selectedKeyboard.isoCode, name: selectedKeyboard.name })}
</p>
</div>
</div>
@ -224,7 +224,7 @@ export default function PasteModal() {
<Button
size="SM"
theme="blank"
text="Cancel"
text={m.cancel()}
onClick={() => {
onCancelPasteMode();
close();
@ -233,7 +233,7 @@ export default function PasteModal() {
<Button
size="SM"
theme="primary"
text="Confirm Paste"
text={m.paste_modal_confirm_paste()}
disabled={isPasteInProgress}
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}

View File

@ -1,8 +1,9 @@
import { useState, useRef } from "react";
import { LuPlus, LuArrowLeft } from "react-icons/lu";
import { InputFieldWithLabel } from "@/components/InputField";
import { Button } from "@/components/Button";
import { m } from "@localizations/messages.js";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
@ -34,8 +35,8 @@ export default function AddDeviceForm({
>
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
placeholder={m.wake_on_lan_add_device_example_device_name()}
label={m.wake_on_lan_add_device_device_name()}
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
@ -46,7 +47,7 @@ export default function AddDeviceForm({
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
label={m.wake_on_lan_add_device_mac_address()}
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
@ -82,14 +83,14 @@ export default function AddDeviceForm({
<Button
size="SM"
theme="light"
text="Back"
text={m.wake_on_lan_add_device_back()}
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
text={m.wake_on_lan_add_device_save_device()}
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";

View File

@ -1,8 +1,9 @@
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import Card from "@/components/Card";
import { FieldError } from "@/components/InputField";
import { m } from "@localizations/messages.js";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
export interface StoredDevice {
name: string;
@ -46,7 +47,7 @@ export default function DeviceList({
<Button
size="XS"
theme="light"
text="Wake"
text={m.wake_on_lan_device_list_wake()}
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
@ -55,7 +56,7 @@ export default function DeviceList({
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
aria-label={m.wake_on_lan_device_list_delete_device()}
/>
</div>
</div>
@ -69,11 +70,11 @@ export default function DeviceList({
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button size="SM" theme="blank" text={m.close()} onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
text={m.wake_on_lan_device_list_add_new_device()}
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>

View File

@ -1,8 +1,9 @@
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import Card from "@/components/Card";
import { Button } from "@/components/Button";
import { m } from "@localizations/messages.js";
import Card from "@components/Card";
import { Button } from "@components/Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
@ -25,10 +26,10 @@ export default function EmptyStateCard({
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
{m.wake_on_lan_empty_no_devices_added()}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
{m.wake_on_lan_empty_add_device_to_start()}
</p>
</div>
</div>
@ -41,11 +42,11 @@ export default function EmptyStateCard({
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button size="SM" theme="blank" text={m.close()} onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
text={m.wake_on_lan_empty_add_new_device()}
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import { m } from "@localizations/messages.js";
import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@hooks/stores";
import notifications from "@/notifications";
import EmptyStateCard from "./EmptyStateCard";
@ -35,12 +36,12 @@ export default function WakeOnLanModal() {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
setErrorMessage("Invalid MAC address");
setErrorMessage(m.wake_on_lan_invalid_mac());
} else {
setErrorMessage("Failed to send Magic Packet");
setErrorMessage(m.wake_on_lan_failed_send_magic());
}
} else {
notifications.success("Magic Packet sent successfully");
notifications.success(m.wake_on_lan_magic_sent_success());
setDisableVideoFocusTrap(false);
close();
}
@ -87,7 +88,7 @@ export default function WakeOnLanModal() {
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");
setAddDeviceErrorMessage(m.wake_on_lan_failed_add_device());
} else {
setShowAddForm(false);
syncStoredDevices();
@ -103,8 +104,8 @@ export default function WakeOnLanModal() {
<div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4">
<SettingsPageHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
title={m.wake_on_lan()}
description={m.wake_on_lan_description()}
/>
{showAddForm ? (

View File

@ -1,12 +1,12 @@
import { useInterval } from "usehooks-ts";
import SidebarHeader from "@/components/SidebarHeader";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import { m } from "@localizations/messages.js";
import { useRTCStore, useUiStore } from "@hooks/stores";
import { createChartArray, Metric } from "@components/Metric";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import SidebarHeader from "@components/SidebarHeader";
import { someIterable } from "@/utils";
import { createChartArray, Metric } from "../Metric";
import { SettingsSectionHeader } from "../SettingsSectionHeader";
export default function ConnectionStatsSidebar() {
const { sidebarView, setSidebarView } = useUiStore();
const {
@ -95,7 +95,7 @@ export default function ConnectionStatsSidebar() {
return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<SidebarHeader title={m.connection_stats_sidebar()} setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4">
{sidebarView === "connection-stats" && (
@ -103,12 +103,12 @@ export default function ConnectionStatsSidebar() {
{/* Connection Group */}
<div className="space-y-3">
<SettingsSectionHeader
title="Connection"
description="The connection between the client and the JetKVM."
title={m.connection_stats_connection()}
description={m.connection_stats_connection_description()}
/>
<Metric
title="Round-Trip Time"
description="Round-trip time for the active ICE candidate pair between peers."
title={m.connection_stats_round_trip_time()}
description={m.connection_stats_round_trip_time_description()}
stream={iceCandidatePairStats}
metric="currentRoundTripTime"
map={x => ({
@ -123,16 +123,16 @@ export default function ConnectionStatsSidebar() {
{/* Video Group */}
<div className="space-y-3">
<SettingsSectionHeader
title="Video"
description="The video stream from the JetKVM to the client."
title={m.connection_stats_video()}
description={m.connection_stats_video_description()}
/>
{/* RTP Jitter */}
<Metric
title="Network Stability"
badge="Jitter"
title={m.connection_stats_network_stability()}
badge={m.connection_stats_badge_jitter()}
badgeTheme="light"
description="How steady the flow of inbound video packets is across the network."
description={m.connection_stats_network_stability_description()}
stream={inboundVideoRtpStats}
metric="jitter"
map={x => ({
@ -145,9 +145,9 @@ export default function ConnectionStatsSidebar() {
{/* Playback Delay */}
<Metric
title="Playback Delay"
description="Delay added by the jitter buffer to smooth playback when frames arrive unevenly."
badge="Jitter Buffer Avg. Delay"
title={m.connection_stats_playback_delay()}
description={m.connection_stats_playback_delay_description()}
badge={m.connection_stats_badge_jitter_buffer_avg_delay()}
badgeTheme="light"
data={jitterBufferAvgDelayData}
gate={inboundVideoRtpStats}
@ -167,8 +167,8 @@ export default function ConnectionStatsSidebar() {
{/* Packets Lost */}
<Metric
title="Packets Lost"
description="Count of lost inbound video RTP packets."
title={m.connection_stats_packets_lost()}
description={m.connection_stats_packets_lost_description()}
stream={inboundVideoRtpStats}
metric="packetsLost"
domain={[0, 100]}
@ -177,8 +177,8 @@ export default function ConnectionStatsSidebar() {
{/* Frames Per Second */}
<Metric
title="Frames per second"
description="Number of inbound video frames displayed per second."
title={m.connection_stats_frames_per_second()}
description={m.connection_stats_frames_per_second_description()}
stream={inboundVideoRtpStats}
metric="framesPerSecond"
domain={[0, 80]}

View File

@ -245,6 +245,8 @@ export class KeyboardMacroReportMessage extends RpcMessage {
...fromUint32toUint8(this.stepCount),
]), 0);
let offset = 6;
for (let i = 0; i < this.stepCount; i++) {
const step = this.steps[i];
if (!withinUint8Range(step.modifier)) {
@ -270,10 +272,9 @@ export class KeyboardMacroReportMessage extends RpcMessage {
...keys,
...fromUint16toUint8(step.delay),
]);
const offset = 6 + i * 9;
data.set(macroBinary, offset);
offset += 9;
}
return data;

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { useRTCStore } from "@/hooks/stores";
import { useRTCStore } from "@hooks/stores";
import {
CancelKeyboardMacroReportMessage,

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react";
import { useRTCStore } from "@/hooks/stores";
import { useRTCStore } from "@hooks/stores";
export interface JsonRpcRequest {
jsonrpc: string;

View File

@ -54,7 +54,8 @@ export default function useKeyboard() {
// support the keyPressReport API. In that case, we need to handle the key presses locally
// and send the full state to the device, so it can behave like a real USB HID keyboard.
// This flag indicates whether the keyPressReport API is available on the device which is
// dynamically set when the device responds to the first key press event or reports its // keysDownState when queried since the keyPressReport was introduced together with the
// dynamically set when the device responds to the first key press event or reports its
// keysDownState when queried since the keyPressReport was introduced together with the
// getKeysDownState API.
// HidRPC is a binary format for exchanging keyboard and mouse events
@ -148,66 +149,6 @@ export default function useKeyboard() {
}
}, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport, cancelKeepAlive]);
// handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events.
// It checks if the keyPressReport API is available and sends the key press event.
// If the keyPressReport API is not available, it simulates the device-side key
// handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device.
const sendKeypress = useCallback(
(key: number, press: boolean) => {
cancelKeepAlive();
sendKeypressEventHidRpc(key, press);
if (press) {
scheduleKeepAlive();
}
},
[sendKeypressEventHidRpc, scheduleKeepAlive, cancelKeepAlive],
);
const handleKeyPress = useCallback(
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings)
if (rpcHidReady) {
// if the keyPress api is available, we can just send the key press event
// sendKeypressEvent is used to send a single key press/release event to the device.
// It sends the key and whether it is pressed or released.
// Older device version doesn't support this API, so we will switch to local key handling
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypress(key, press);
} else {
// Older backends don't support the hidRpc API, so we need:
// 1. Calculate the state
// 2. Send the newly calculated state to the device
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(
keysDownState,
key,
press,
);
handleLegacyKeyboardReport(downState.keys, downState.modifier);
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
resetKeyboardState();
}
}
},
[
rpcDataChannel?.readyState,
rpcHidReady,
keysDownState,
handleLegacyKeyboardReport,
resetKeyboardState,
sendKeypress,
],
);
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
function simulateDeviceSideKeyHandlingForLegacyDevices(
@ -272,12 +213,71 @@ export default function useKeyboard() {
return { modifier: modifiers, keys };
}
const sendKeypress = useCallback(
(key: number, press: boolean) => {
cancelKeepAlive();
sendKeypressEventHidRpc(key, press);
if (press) {
scheduleKeepAlive();
}
},
[sendKeypressEventHidRpc, scheduleKeepAlive, cancelKeepAlive],
);
// handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events.
// It checks if the keyPressReport API is available and sends the key press event.
// If the keyPressReport API is not available, it simulates the device-side key
// handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device.
const handleKeyPress = useCallback(
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings)
if (rpcHidReady) {
// if the keyPress api is available, we can just send the key press event
// sendKeypressEvent is used to send a single key press/release event to the device.
// It sends the key and whether it is pressed or released.
// Older device version doesn't support this API, so we will switch to local key handling
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypress(key, press);
} else {
// Older backends don't support the hidRpc API, so we need:
// 1. Calculate the state
// 2. Send the newly calculated state to the device
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(
keysDownState,
key,
press,
);
handleLegacyKeyboardReport(downState.keys, downState.modifier);
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
resetKeyboardState();
}
}
},
[
rpcDataChannel?.readyState,
rpcHidReady,
keysDownState,
handleLegacyKeyboardReport,
resetKeyboardState,
sendKeypress,
],
);
// Cleanup function to cancel keepalive timer
const cleanup = useCallback(() => {
cancelKeepAlive();
}, [cancelKeepAlive]);
// executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay.
// The keys and modifiers are pressed together and held for the delay duration.
@ -306,6 +306,7 @@ export default function useKeyboard() {
sendKeyboardMacroEventHidRpc(macro);
}, [sendKeyboardMacroEventHidRpc]);
const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
const promises: (() => Promise<void>)[] = [];
@ -355,6 +356,7 @@ export default function useKeyboard() {
});
});
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]);
const executeMacro = useCallback(async (steps: MacroSteps) => {
if (rpcHidReady) {
return executeMacroRemote(steps);

View File

@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useDeviceStore } from "@/hooks/stores";
import { type JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export interface VersionInfo {
appVersion: string;
@ -29,7 +30,7 @@ export function useVersion() {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to check for updates: ${resp.error}`);
notifications.error(m.updates_failed_check({ error: String(resp.error) }));
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
@ -37,7 +38,7 @@ export function useVersion() {
setSystemVersion(result.local.systemVersion);
if (result.error) {
notifications.error(`Failed to check for updates: ${result.error}`);
notifications.error(m.updates_failed_check({ error: String(result.error) }));
reject(new Error("Failed to check for updates"));
} else {
resolve(result);
@ -57,7 +58,7 @@ export function useVersion() {
return getVersionInfo().then(result => resolve(result.local)).catch(reject);
}
console.error("Failed to get device version N", resp.error);
notifications.error(`Failed to get device version: ${resp.error}`);
notifications.error(m.updates_failed_get_device_version({ error: String(resp.error) }));
reject(new Error("Failed to get device version"));
} else {
const result = resp.result as VersionInfo;

View File

@ -1,6 +1,5 @@
import { lazy } from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import {
createBrowserRouter,
isRouteErrorResponse,
@ -8,11 +7,13 @@ import {
RouterProvider,
useRouteError,
} from "react-router";
import "./index.css";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api";
import Root from "@/root";
import { m } from "@localizations/messages.js";
import Card from "@components/Card";
import EmptyCard from "@components/EmptyCard";
import NotFoundPage from "@components/NotFoundPage";
@ -33,7 +34,7 @@ const SignupRoute = lazy(() => import("@routes/signup"));
const LoginRoute = lazy(() => import("@routes/login"));
const DevicesAlreadyAdopted = lazy(() => import("@routes/devices.already-adopted"));
const OtherSessionRoute = lazy(() => import("@routes/devices.$id.other-session"));
const MountRoute = lazy(() => import("./routes/devices.$id.mount"));
const MountRoute = lazy(() => import("@routes/devices.$id.mount"));
const SettingsRoute = lazy(() => import("@routes/devices.$id.settings"));
const SettingsMouseRoute = lazy(() => import("@routes/devices.$id.settings.mouse"));
const SettingsKeyboardRoute = lazy(() => import("@routes/devices.$id.settings.keyboard"));
@ -116,7 +117,7 @@ if (isOnDevice) {
path: "/",
errorElement: <ErrorBoundary />,
element: <DeviceRoute />,
HydrateFallback: () => <div className="p-4">Loading...</div>,
HydrateFallback: () => <div className="p-4">{m.loading()}</div>,
loader: DeviceRoute.loader,
children: [
{
@ -390,22 +391,46 @@ document.addEventListener("DOMContentLoaded", () => {
// eslint-disable-next-line react-refresh/only-export-components
function ErrorBoundary() {
const error = useRouteError();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const errorMessage = error?.data?.error?.message || error?.message;
if (isRouteErrorResponse(error)) {
if (error.status === 404) return <NotFoundPage />;
}
const getErrorMessage = (err: unknown): string | null => {
// If it's a route error response, try to read a string at err.data.error.message or err.data.error safely
if (isRouteErrorResponse(err)) {
const data = (err as { data?: unknown }).data;
if (data && typeof data === "object") {
const maybeError = (data as Record<string, unknown>)["error"];
if (maybeError) {
if (typeof maybeError === "object") {
const msg = (maybeError as Record<string, unknown>)["message"];
if (typeof msg === "string") return msg;
} else if (typeof maybeError === "string") {
return maybeError;
}
}
}
}
// Fallback: check plain object message property
if (err && typeof err === "object") {
const maybeMsg = (err as Record<string, unknown>)["message"];
if (typeof maybeMsg === "string") return maybeMsg;
}
return null;
};
const errorMessage = getErrorMessage(error);
return (
<div className="h-full w-full">
<div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Oh no!"
description="Something went wrong. Please try again later or contact support"
headline={m.oh_no()}
description={m.something_went_wrong()}
BtnElm={
errorMessage && (
<Card>

View File

@ -1,9 +1,8 @@
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
import React, { useEffect } from "react";
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import Card from "@components/Card";
interface NotificationOptions {
duration?: number;
@ -34,7 +33,7 @@ const ToastContent = ({
const notifications = {
success: (message: string, options?: NotificationOptions) => {
return toast.custom(
t => (
(t: Toast) => (
<ToastContent
icon={<CheckCircleIcon className="w-5 h-5 text-green-500 dark:text-green-400" />}
message={message}
@ -47,7 +46,7 @@ const notifications = {
error: (message: string, options?: NotificationOptions) => {
return toast.custom(
t => (
(t: Toast) => (
<ToastContent
icon={<XCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400" />}
message={message}
@ -64,9 +63,9 @@ function useMaxToasts(max: number) {
useEffect(() => {
toasts
.filter(t => t.visible) // Only consider visible toasts
.filter((_, i) => i >= max) // Is toast index over limit?
.forEach(t => toast.dismiss(t.id)); // Dismiss Use toast.remove(t.id) for no exit animation
.filter((t: Toast) => t.visible) // Only consider visible toasts
.filter((_: Toast, i: number) => i >= max) // Is toast index over limit?
.forEach((t: Toast) => toast.dismiss(t.id)); // Dismiss Use toast.remove(t.id) for no exit animation
}, [toasts, max]);
}

View File

@ -2,14 +2,15 @@ import { Form, redirect, useActionData, useLoaderData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { User } from "@hooks/stores";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader";
import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { checkAuth } from "@/main";
import { CLOUD_API } from "@/ui.config";
import { m } from "@localizations/messages.js";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
@ -28,11 +29,12 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
});
if (!res.ok) {
return { message: "There was an error deregistering your device. Please try again." };
return { message: m.deregister_error({ status: res.statusText }) };
}
} catch (e) {
console.error(e);
return { message: "There was an error deregistering your device. Please try again." };
const message = e instanceof Error ? e.message : String(e);
return { message: m.deregister_error({ status: message }) };
}
return redirect("/devices");
@ -68,7 +70,7 @@ export default function DevicesIdDeregister() {
<div className="grid min-h-screen grid-rows-(--grid-layout)">
<DashboardNavbar
isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
primaryLinks={[{ title: m.deregister_cloud_devices(), to: "/devices" }]}
userEmail={user?.email}
picture={user?.picture}
kvmName={device?.name}
@ -82,21 +84,14 @@ export default function DevicesIdDeregister() {
size="SM"
theme="blank"
LeadingIcon={ChevronLeftIcon}
text="Back to Devices"
text={m.back_to_devices()}
to="/devices"
/>
<Card className="max-w-3xl p-6">
<div className="max-w-xl space-y-4">
<CardHeader
headline={`Deregister ${device.name || device.id} from your cloud account`}
description={
<>
This will remove the device from your cloud account and revoke
remote access to it.
<br />
Please note that local access will still be possible
</>
}
headline={m.deregister_headline({ device: device.name || device.id })}
description={m.deregister_description()}
/>
<Fieldset>
@ -107,20 +102,20 @@ export default function DevicesIdDeregister() {
size="MD"
theme="light"
to="/devices"
text="Cancel"
text={m.cancel()}
textAlign="center"
/>
<Button
size="MD"
theme="danger"
type="submit"
text="Deregister from Cloud"
text={m.deregister_button()}
textAlign="center"
/>
</div>
{error?.message && (
<p className="text-sm text-red-500 dark:text-red-400">
{error?.message}
{m.deregister_error({ status: error.message })}
</p>
)}
</Form>

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router";
import {
LuLink,
LuRadioReceiver,
@ -7,28 +8,28 @@ import {
} from "react-icons/lu";
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router";
import Card, { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { formatters } from "@/utils";
import DebianIcon from "@assets/debian-icon.png";
import UbuntuIcon from "@assets/ubuntu-icon.png";
import FedoraIcon from "@assets/fedora-icon.png";
import OpenSUSEIcon from "@assets/opensuse-icon.png";
import ArchIcon from "@assets/arch-icon.png";
import NetBootIcon from "@assets/netboot-icon.svg";
import LogoBlueIcon from "@assets/logo-blue.svg";
import LogoWhiteIcon from "@assets/logo-white.svg";
import { cx } from "@/cva.config";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import AutoHeight from "@components/AutoHeight";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@/components/InputField";
import DebianIcon from "@/assets/debian-icon.png";
import UbuntuIcon from "@/assets/ubuntu-icon.png";
import FedoraIcon from "@/assets/fedora-icon.png";
import OpenSUSEIcon from "@/assets/opensuse-icon.png";
import ArchIcon from "@/assets/arch-icon.png";
import NetBootIcon from "@/assets/netboot-icon.svg";
import Fieldset from "@/components/Fieldset";
import { formatters } from "@/utils";
import { DEVICE_API } from "@/ui.config";
import { isOnDevice } from "@/main";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import { cx } from "../cva.config";
import {
MountMediaState,
RemoteVirtualMediaState,
@ -145,12 +146,12 @@ export function Dialog({ onClose }: { onClose: () => void }) {
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<img
src={LogoBlueIcon}
alt="JetKVM Logo"
alt={m.jetkvm_logo()}
className="block h-[24px] dark:hidden"
/>
<img
src={LogoWhiteIcon}
alt="JetKVM Logo"
alt={m.jetkvm_logo()}
className="hidden h-[24px] dark:mt-0! dark:block"
/>
{modalView === "mode" && (
@ -238,26 +239,26 @@ function ModeSelectionView({
<div className="w-full space-y-4">
<div className="animate-fadeIn space-y-0 opacity-0">
<h2 className="text-lg leading-tight font-bold dark:text-white">
Virtual Media Source
{m.mount_virtual_media_source()}
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
Choose how you want to mount your virtual media
{m.mount_virtual_media_source_description()}
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
{[
{
label: "URL Mount",
label: m.mount_url_mount(),
value: "url",
description: "Mount files from any public web address",
description: m.mount_url_description(),
icon: LuLink,
tag: "Experimental",
tag: m.experimental(),
disabled: false,
},
{
label: "JetKVM Storage Mount",
label: m.mount_jetkvm_storage(),
value: "device",
description: "Mount previously uploaded files from the JetKVM storage",
description: m.mount_jetkvm_storage_description(),
icon: LuRadioReceiver,
tag: null,
disabled: false,
@ -332,7 +333,7 @@ function ModeSelectionView({
onClick={() => {
setModalView(selectedMode);
}}
text="Continue"
text={m.continue()}
/>
</div>
</div>
@ -351,6 +352,7 @@ function UrlView({
}) {
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const [url, setUrl] = useState<string>("");
const [isUrlValid, setIsUrlValid] = useState(false);
const popularImages = [
{
@ -398,6 +400,12 @@ function UrlView({
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (urlRef.current) {
setIsUrlValid(urlRef.current.validity.valid);
}
}, [url]);
function handleUrlChange(url: string) {
setUrl(url);
if (url.endsWith(".iso")) {
@ -410,8 +418,8 @@ function UrlView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from URL"
description="Enter an URL to the image file to mount"
title={m.mount_view_url_title()}
description={m.mount_view_url_description()}
/>
<div
@ -423,7 +431,7 @@ function UrlView({
<InputFieldWithLabel
placeholder="https://example.com/image.iso"
type="url"
label="Image URL"
label={m.mount_url_input_label()}
ref={urlRef}
value={url}
onChange={e => handleUrlChange(e.target.value)}
@ -436,19 +444,19 @@ function UrlView({
animationDelay: "0.1s",
}}
>
<Fieldset disabled={!urlRef.current?.validity.valid || url.length === 0}>
<Fieldset disabled={!isUrlValid || url.length === 0}>
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset>
<div className="flex space-x-2">
<Button size="MD" theme="blank" text="Back" onClick={onBack} />
<Button size="MD" theme="blank" text={m.back()} onClick={onBack} />
<Button
size="MD"
theme="primary"
loading={mountInProgress}
text="Mount URL"
text={m.mount_button_mount_url()}
onClick={() => onMount(url, usbMode)}
disabled={
mountInProgress || !urlRef.current?.validity.valid || url.length === 0
mountInProgress || !isUrlValid || url.length === 0
}
/>
</div>
@ -463,7 +471,7 @@ function UrlView({
}}
>
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images
{m.mount_popular_images()}
</h2>
<Card className="w-full divide-y divide-slate-800/20 dark:divide-slate-300/20">
{popularImages.map((image, index) => (
@ -487,7 +495,7 @@ function UrlView({
<Button
size="XS"
theme="light"
text="Select"
text={m.mount_button_select()}
onClick={() => handleUrlChange(image.url)}
/>
</div>
@ -553,7 +561,7 @@ function DeviceFileView({
const syncStorage = useCallback(() => {
send("listStorageFiles", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Error listing storage files: ${resp.error}`);
notifications.error(m.mount_error_list_storage({ error: resp.error }));
return;
}
const { files } = resp.result as StorageFiles;
@ -568,7 +576,7 @@ function DeviceFileView({
send("getStorageSpace", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Error getting storage space: ${resp.error}`);
notifications.error(m.mount_error_get_storage_space({ error: resp.error }));
return;
}
@ -597,7 +605,7 @@ function DeviceFileView({
console.log("Deleting file:", file);
send("deleteStorageFile", { filename: file.name }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Error deleting file: ${resp.error}`);
notifications.error(m.mount_error_delete_file({ error: resp.error }));
return;
}
@ -630,8 +638,8 @@ function DeviceFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from JetKVM Storage"
description="Select an image to mount from the JetKVM storage"
title={m.mount_view_device_title()}
description={m.mount_view_device_description()}
/>
<div
className="w-full animate-fadeIn opacity-0"
@ -647,17 +655,17 @@ function DeviceFileView({
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
No images available
{m.mount_no_images_title()}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Upload an image to start virtual media mounting.
{m.mount_no_images_description()}
</p>
</div>
<div>
<Button
size="SM"
theme="primary"
text="Upload a new image"
text={m.mount_upload_title()}
onClick={() => onNewImageClick()}
/>
</div>
@ -677,9 +685,7 @@ function DeviceFileView({
const selectedFile = onStorageFiles.find(f => f.name === file.name);
if (!selectedFile) return;
if (
window.confirm(
"Are you sure you want to delete " + selectedFile.name + "?",
)
window.confirm(m.mount_confirm_delete({ name: selectedFile.name }))
) {
handleDeleteFile(selectedFile);
}
@ -692,24 +698,24 @@ function DeviceFileView({
{onStorageFiles.length > filesPerPage && (
<div className="flex items-center justify-between px-3 py-2">
<p className="text-sm text-slate-700 dark:text-slate-300">
Showing <span className="font-bold">{indexOfFirstFile + 1}</span> to{" "}
<span className="font-bold">
{Math.min(indexOfLastFile, onStorageFiles.length)}
</span>{" "}
of <span className="font-bold">{onStorageFiles.length}</span> results
{m.mount_button_showing_results({
from: indexOfFirstFile + 1,
to: Math.min(indexOfLastFile, onStorageFiles.length),
total: onStorageFiles.length
})}
</p>
<div className="flex items-center gap-x-2">
<Button
size="XS"
theme="light"
text="Previous"
text={m.previous()}
onClick={handlePreviousPage}
disabled={currentPage === 1}
/>
<Button
size="XS"
theme="light"
text="Next"
text={m.next()}
onClick={handleNextPage}
disabled={currentPage === totalPages}
/>
@ -738,7 +744,7 @@ function DeviceFileView({
size="MD"
disabled={selected === null || mountInProgress}
theme="primary"
text="Mount File"
text={m.mount_button_mount_file()}
loading={mountInProgress}
onClick={() =>
onMountStorageFile(
@ -772,10 +778,10 @@ function DeviceFileView({
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">
Available Storage
{m.mount_available_storage()}
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
{m.mount_percentage_used({ percentageUsed })}
</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700">
@ -786,10 +792,10 @@ function DeviceFileView({
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
{m.mount_bytes_used({ bytesUsed: formatters.bytes(bytesUsed) })}
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
{m.mount_bytes_free({ bytesFree: formatters.bytes(bytesFree) })}
</span>
</div>
</div>
@ -806,7 +812,7 @@ function DeviceFileView({
size="MD"
theme="light"
fullWidth
text="Upload a new image"
text={m.mount_button_upload_new_image()}
onClick={() => onNewImageClick()}
/>
</div>
@ -862,7 +868,7 @@ function UploadFileView({
if (!rtcDataChannel) {
console.error("Failed to create data channel for file upload");
notifications.error("Failed to create data channel for file upload");
notifications.error(m.mount_upload_failed_datachannel());
setUploadState("idle");
console.log("Upload state set to 'idle'");
@ -952,7 +958,7 @@ function UploadFileView({
rtcDataChannel.onerror = error => {
console.error("RTC Data channel error:", error);
notifications.error(`Upload failed: ${error}`);
notifications.error(m.mount_upload_failed_rtc({ error: error }));
setUploadState("idle");
console.log("Upload state set to 'idle'");
};
@ -1037,7 +1043,7 @@ function UploadFileView({
file.name !== incompleteFileName.replace(".incomplete", "")
) {
setFileError(
`Please select the file "${incompleteFileName.replace(".incomplete", "")}" to continue the upload.`,
m.mount_please_select_file({ name: incompleteFileName.replace(".incomplete", "") }),
);
return;
}
@ -1080,11 +1086,11 @@ function UploadFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Upload New Image"
title={m.mount_upload_title()}
description={
incompleteFileName
? `Continue uploading "${incompleteFileName}"`
: "Select an image file to upload to JetKVM storage"
? m.mount_continue_uploading_with_name({ name: incompleteFileName.replace(".incomplete", "") })
: m.mount_upload_description()
}
/>
<div
@ -1121,11 +1127,11 @@ function UploadFileView({
</div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
{incompleteFileName
? `Click to select "${incompleteFileName.replace(".incomplete", "")}"`
: "Click to select a file"}
? m.mount_click_to_select_incomplete({ name: incompleteFileName.replace(".incomplete", "") })
: m.mount_click_to_select_file()}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Supported formats: ISO, IMG
{m.mount_supported_formats()}
</p>
</div>
)}
@ -1140,7 +1146,7 @@ function UploadFileView({
</Card>
</div>
<h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
{m.mount_uploading_with_name({ name: formatters.truncateMiddle(uploadedFileName, 30) })}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.bytes(uploadedFileSize || 0)}
@ -1153,11 +1159,11 @@ function UploadFileView({
></div>
</div>
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
<span>Uploading...</span>
<span>{m.mount_uploading()}</span>
<span>
{uploadSpeed !== null
? `${formatters.bytes(uploadSpeed)}/s`
: "Calculating..."}
: m.mount_calculating()}
</span>
</div>
</div>
@ -1174,11 +1180,10 @@ function UploadFileView({
</Card>
</div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
Upload successful
{m.mount_upload_successful()}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.truncateMiddle(uploadedFileName, 40)} has been
uploaded
{m.mount_uploaded_has_been_uploaded({ name: formatters.truncateMiddle(uploadedFileName, 40) })}
</p>
</div>
)}
@ -1205,7 +1210,7 @@ function UploadFileView({
className="mt-2 animate-fadeIn truncate text-sm text-red-600 dark:text-red-400 opacity-0"
style={{ animationDuration: "0.7s" }}
>
Error: {uploadError}
{m.mount_upload_error({ error: String(uploadError) })}
</div>
)}
@ -1221,7 +1226,7 @@ function UploadFileView({
<Button
size="MD"
theme="light"
text="Cancel Upload"
text={m.mount_button_cancel_upload()}
onClick={() => {
onCancelUpload();
setUploadState("idle");
@ -1235,7 +1240,7 @@ function UploadFileView({
<Button
size="MD"
theme={uploadState === "success" ? "primary" : "light"}
text="Back to Overview"
text={m.mount_button_back_to_overview()}
onClick={onBack}
/>
)}
@ -1259,10 +1264,10 @@ function ErrorView({
<div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="h-6 w-6" />
<h2 className="text-lg leading-tight font-bold">Mount Error</h2>
<h2 className="text-lg leading-tight font-bold">{m.mount_error_title()}</h2>
</div>
<p className="text-sm leading-snug text-slate-600">
An error occurred while attempting to mount the media. Please try again.
{m.mount_error_description()}
</p>
</div>
{errorMessage && (
@ -1271,8 +1276,8 @@ function ErrorView({
</Card>
)}
<div className="flex justify-end space-x-2">
<Button size="SM" theme="light" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Back to Overview" onClick={onRetry} />
<Button size="SM" theme="light" text={m.close()} onClick={onClose} />
<Button size="SM" theme="primary" text={m.mount_button_back_to_overview()} onClick={onRetry} />
</div>
</div>
);
@ -1341,7 +1346,7 @@ function PreUploadedImageItem({
size="XS"
theme="light"
LeadingIcon={TrashIcon}
text="Delete"
text={m.delete()}
onClick={e => {
e.stopPropagation();
onDelete();
@ -1362,7 +1367,7 @@ function PreUploadedImageItem({
<Button
size="XS"
theme="light"
text="Continue uploading"
text={m.mount_button_continue_upload()}
onClick={e => {
e.stopPropagation();
onContinueUpload();
@ -1408,7 +1413,7 @@ function UsbModeSelector({
className="form-radio h-3 w-3 rounded-full border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/>
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
CD/DVD
{m.mount_mode_cdrom()}
</span>
</label>
<label htmlFor="disk" className="flex items-center">
@ -1421,7 +1426,7 @@ function UsbModeSelector({
className="form-radio h-3 w-3 rounded-full border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/>
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
Disk
{m.mount_mode_disk()}
</span>
</label>
</div>

View File

@ -1,9 +1,10 @@
import { useNavigate, useOutletContext } from "react-router";
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import { GridCard } from "@components/Card";
import LogoBlue from "@assets/logo-blue.svg";
import LogoWhite from "@assets/logo-white.svg";
import { m } from "@localizations/messages";
interface ContextType {
setupPeerConnection: () => Promise<void>;
@ -30,14 +31,13 @@ export default function OtherSessionRoute() {
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Another Active Session Detected
{m.other_session_detected()}
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Only one active session is supported at a time. Would you like to take over
this session?
{m.other_session_take_over()}
</p>
<div className="flex items-center justify-start space-x-4">
<Button size="SM" theme="primary" text="Use Here" onClick={handleClose} />
<Button size="SM" theme="primary" text={m.other_session_use_here_button()} onClick={handleClose} />
</div>
</div>
</div>

View File

@ -2,17 +2,17 @@ import { Form, redirect, useActionData, useLoaderData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { User } from "@hooks/stores";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader";
import { InputFieldWithLabel } from "@components/InputField";
import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import { checkAuth } from "@/main";
import { CLOUD_API } from "@/ui.config";
import api from "../api";
import api from "@/api";
import { m } from "@localizations/messages";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
@ -24,7 +24,7 @@ const action: ActionFunction = async ({ params, request }: ActionFunctionArgs) =
const { name } = Object.fromEntries(await request.formData());
if (!name || name === "") {
return { message: "Please specify a name" };
return { message: m.rename_device_no_name() };
}
try {
@ -32,11 +32,11 @@ const action: ActionFunction = async ({ params, request }: ActionFunctionArgs) =
name,
});
if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." };
return { message: m.rename_device_error({ error: res.statusText }) };
}
} catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." };
return { message: m.rename_device_error({ error: String(e) }) };
}
return redirect("/devices");
@ -86,24 +86,24 @@ export default function DeviceIdRename() {
size="SM"
theme="blank"
LeadingIcon={ChevronLeftIcon}
text="Back to Devices"
text={m.back_to_devices()}
to="/devices"
/>
<Card className="max-w-3xl p-6">
<div className="space-y-4">
<CardHeader
headline={`Rename ${device.name || device.id}`}
description="Properly name your device to easily identify it."
headline={m.rename_device_headline({ name: device.name || device.id })}
description={m.rename_device_description()}
/>
<Fieldset>
<Form method="POST" className="max-w-sm space-y-4">
<div className="group relative">
<InputFieldWithLabel
label="New device name"
label={m.rename_device_new_name_label()}
type="text"
name="name"
placeholder="Plex Media Server"
placeholder={m.rename_device_new_name_placeholder()}
size="MD"
autoFocus
error={error?.message.toString()}
@ -114,7 +114,7 @@ export default function DeviceIdRename() {
size="MD"
theme="primary"
type="submit"
text="Rename Device"
text={m.rename_device_button()}
textAlign="center"
/>
</Form>

View File

@ -1,7 +1,7 @@
import { redirect } from "react-router";
import type { LoaderFunction, LoaderFunctionArgs } from "react-router";
import { getDeviceUiPath } from "../hooks/useAppNavigation";
import { getDeviceUiPath } from "@hooks/useAppNavigation";
const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => {
return redirect(getDeviceUiPath("/settings/general", params.id));

View File

@ -1,22 +1,22 @@
import { useLoaderData, useNavigate } from "react-router";
import type { LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useCallback, useEffect, useState } from "react";
import { useLoaderData, useNavigate, type LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import api from "@/api";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { GridCard } from "@/components/Card";
import { Button, LinkButton } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { GridCard } from "@components/Card";
import { Button, LinkButton } from "@components/Button";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { TextAreaWithLabel } from "@components/TextArea";
import api from "@/api";
import notifications from "@/notifications";
import { DEVICE_API } from "@/ui.config";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { isOnDevice } from "@/main";
import { TextAreaWithLabel } from "@components/TextArea";
import { m } from "@localizations/messages.js";
import { LocalDevice } from "./devices.$id";
import { CloudState } from "./adopt";
@ -92,7 +92,7 @@ export default function SettingsAccessIndexRoute() {
send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
m.access_failed_deregister({ error: resp.error.data || "Unknown error" }),
);
return;
}
@ -107,14 +107,14 @@ export default function SettingsAccessIndexRoute() {
const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error("No device ID available");
notifications.error(m.access_no_device_id());
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
m.access_failed_update_cloud_url({ error: resp.error.data || "Unknown error" }),
);
return;
}
@ -160,12 +160,12 @@ export default function SettingsAccessIndexRoute() {
send("setTLSState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update TLS settings: ${resp.error.data || "Unknown error"}`,
m.access_failed_update_tls({ error: resp.error.data || "Unknown error" }),
);
return;
}
notifications.success("TLS settings updated successfully");
notifications.success(m.access_tls_updated());
});
}, [send]);
@ -206,22 +206,22 @@ export default function SettingsAccessIndexRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Access"
description="Manage the Access Control of the device"
title={m.access_title()}
description={m.access_description()}
/>
{loaderData?.authMode && (
<>
<div className="space-y-4">
<SettingsSectionHeader
title="Local"
description="Manage the mode of local access to the device"
title={m.access_local_title()}
description={m.access_local_description()}
/>
<>
<SettingsItem
title="HTTPS Mode"
title={m.access_https_mode_title()}
badge="Experimental"
description="Configure secure HTTPS access to your device"
description={m.access_https_description()}
>
<SelectMenuBasic
size="SM"
@ -229,9 +229,9 @@ export default function SettingsAccessIndexRoute() {
onChange={e => handleTlsModeChange(e.target.value)}
disabled={tlsMode === "unknown"}
options={[
{ value: "disabled", label: "Disabled" },
{ value: "self-signed", label: "Self-signed" },
{ value: "custom", label: "Custom" },
{ value: "disabled", label: m.access_tls_disabled() },
{ value: "self-signed", label: m.access_tls_self_signed() },
{ value: "custom", label: m.access_tls_custom() },
]}
/>
</SettingsItem>
@ -240,12 +240,12 @@ export default function SettingsAccessIndexRoute() {
<div className="mt-4 space-y-4">
<div className="space-y-4">
<SettingsItem
title="TLS Certificate"
description="Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates)."
title={m.access_tls_certificate_title()}
description={m.access_tls_certificate_description()}
/>
<div className="space-y-4">
<TextAreaWithLabel
label="Certificate"
label={m.access_certificate_label()}
rows={3}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
@ -258,8 +258,8 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4">
<div className="space-y-4">
<TextAreaWithLabel
label="Private Key"
description="For security reasons, it will not be displayed after saving."
label={m.access_private_key_label()}
description={m.access_private_key_description()}
rows={3}
placeholder={
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
@ -274,7 +274,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="primary"
text="Update TLS Settings"
text={m.access_update_tls_settings()}
onClick={handleCustomTlsUpdate}
/>
</div>
@ -282,14 +282,14 @@ export default function SettingsAccessIndexRoute() {
)}
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${loaderData.authMode === "password" ? "Password protected" : "No password"}`}
title={m.access_authentication_mode_title()}
description={loaderData.authMode === "password" ? m.access_auth_mode_password() : m.access_auth_mode_no_password()}
>
{loaderData.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
text={m.access_disable_protection()}
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
@ -298,7 +298,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="light"
text="Enable Password"
text={m.access_enable_password()}
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
@ -309,13 +309,13 @@ export default function SettingsAccessIndexRoute() {
{loaderData.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
title={m.access_change_password_title()}
description={m.access_change_password_description()}
>
<Button
size="SM"
theme="light"
text="Change Password"
text={m.access_change_password_button()}
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
@ -330,23 +330,23 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description="Manage the mode of Remote access to the device"
description={m.access_remote_description()}
/>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title="Cloud Provider"
description="Select the cloud provider for your device"
title={m.access_cloud_provider_title()}
description={m.access_cloud_provider_description()}
>
<SelectMenuBasic
size="SM"
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={[
{ value: "jetkvm", label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
{ value: "jetkvm", label: m.access_provider_jetkvm() },
{ value: "custom", label: m.access_provider_custom() },
]}
/>
</SettingsItem>
@ -356,7 +356,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud API URL"
label={m.access_cloud_api_url_label()}
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
@ -365,7 +365,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud App URL"
label={m.access_cloud_app_url_label()}
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
@ -384,26 +384,26 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
{m.access_cloud_security_title()}
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>Zero Trust security model</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All streams encrypted in transit</li>
<li>{m.access_security_encryption()}</li>
<li>{m.access_security_zero_trust()}</li>
<li>{m.access_security_oidc()}</li>
<li>{m.access_security_streams()}</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
{m.access_security_open_source()}{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
{m.access_github_link()}
</a>
.
</div>
@ -415,7 +415,7 @@ export default function SettingsAccessIndexRoute() {
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
text={m.access_learn_security()}
/>
</div>
</div>
@ -429,32 +429,32 @@ export default function SettingsAccessIndexRoute() {
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
text={m.access_adopt_kvm()}
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to the Cloud
{m.access_adopted_message()}
</p>
<div>
<Button
size="SM"
theme="light"
text="De-register from Cloud"
text={m.access_deregister()}
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
m.access_confirm_deregister(),
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
notifications.error(m.access_no_device_id());
}
}}
/>

View File

@ -1,11 +1,12 @@
import { useState, useEffect } from "react";
import { useLocation, useRevalidator } from "react-router";
import { useLocalAuthModalStore } from "@hooks/stores";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { m } from "@localizations/messages.js";
export default function SecurityAccessLocalAuthRoute() {
const { setModalView } = useLocalAuthModalStore();
@ -34,12 +35,12 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
setError("Please enter a password");
setError(m.local_auth_error_enter_password());
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
setError(m.local_auth_error_passwords_not_match());
return;
}
@ -51,11 +52,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
setError(data.error || m.local_auth_error_setting_password());
}
} catch (error) {
console.error(error);
setError("An error occurred while setting the password");
setError(m.local_auth_error_setting_password());
}
};
@ -65,17 +66,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
confirmNewPassword: string,
) => {
if (newPassword !== confirmNewPassword) {
setError("Passwords do not match");
setError(m.local_auth_error_passwords_not_match());
return;
}
if (oldPassword === "") {
setError("Please enter your old password");
setError(m.local_auth_error_enter_old_password());
return;
}
if (newPassword === "") {
setError("Please enter a new password");
setError(m.local_auth_error_enter_new_password());
return;
}
@ -91,17 +92,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
setError(data.error || m.local_auth_error_changing_password());
}
} catch (error) {
console.error(error);
setError("An error occurred while changing the password");
setError(m.local_auth_error_changing_password());
}
};
const handleDeletePassword = async (password: string) => {
if (password === "") {
setError("Please enter your current password");
setError(m.local_auth_error_enter_current_password());
return;
}
@ -113,11 +114,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
setError(data.error || m.local_auth_error_disabling_password());
}
} catch (error) {
console.error(error);
setError("An error occurred while disabling the password");
setError(m.local_auth_error_disabling_password());
}
};
@ -150,24 +151,24 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "creationSuccess" && (
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
headline={m.local_auth_success_password_set_title()}
description={m.local_auth_success_password_set_description()}
onClose={onClose}
/>
)}
{modalView === "deleteSuccess" && (
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
headline={m.local_auth_success_password_disabled_title()}
description={m.local_auth_success_password_disabled_description()}
onClose={onClose}
/>
)}
{modalView === "updateSuccess" && (
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
headline={m.local_auth_success_password_updated_title()}
description={m.local_auth_success_password_updated_description()}
onClose={onClose}
/>
)}
@ -198,24 +199,24 @@ function CreatePasswordModal({
>
<div>
<h2 className="text-lg font-semibold dark:text-white">
Local Device Protection
{m.local_auth_create_title()}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
{m.local_auth_create_description()}
</p>
</div>
<InputFieldWithLabel
label="New Password"
label={m.local_auth_create_new_password_label()}
type="password"
placeholder="Enter a strong password"
placeholder={m.local_auth_create_new_password_placeholder()}
value={password}
autoFocus
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
label={m.local_auth_confirm_new_password_label()}
type="password"
placeholder="Re-enter your password"
placeholder={m.local_auth_create_confirm_password_placeholder()}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
@ -224,10 +225,10 @@ function CreatePasswordModal({
<Button
size="SM"
theme="primary"
text="Secure Device"
text={m.local_auth_create_secure_button()}
onClick={() => onSetPassword(password, confirmPassword)}
/>
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
<Button size="SM" theme="light" text={m.local_auth_create_not_now_button()} onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
@ -251,16 +252,16 @@ function DeletePasswordModal({
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection
{m.local_auth_disable_local_device_protection_title()}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
{m.local_auth_disable_local_device_protection_description()}
</p>
</div>
<InputFieldWithLabel
label="Current Password"
label={m.local_auth_current_password_label()}
type="password"
placeholder="Enter your current password"
placeholder={m.local_auth_enter_current_password_placeholder()}
value={password}
onChange={e => setPassword(e.target.value)}
/>
@ -268,10 +269,10 @@ function DeletePasswordModal({
<Button
size="SM"
theme="danger"
text="Disable Protection"
text={m.local_auth_disable_protection_button()}
onClick={() => onDeletePassword(password)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
<Button size="SM" theme="light" text={m.cancel()} onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
@ -306,31 +307,30 @@ function UpdatePasswordModal({
>
<div>
<h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password
{m.local_auth_change_local_device_password_title()}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
{m.local_auth_change_local_device_password_description()}
</p>
</div>
<InputFieldWithLabel
label="Current Password"
label={m.local_auth_current_password_label()}
type="password"
placeholder="Enter your current password"
placeholder={m.local_auth_enter_current_password_placeholder()}
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
<InputFieldWithLabel
label="New Password"
label={m.local_auth_new_password_label()}
type="password"
placeholder="Enter a new strong password"
placeholder={m.local_auth_enter_new_password_placeholder()}
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
label={m.local_auth_confirm_new_password_label()}
type="password"
placeholder="Re-enter your new password"
placeholder={m.local_auth_reenter_new_password_placeholder()}
value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)}
/>
@ -338,10 +338,10 @@ function UpdatePasswordModal({
<Button
size="SM"
theme="primary"
text="Update Password"
text={m.local_auth_update_password_button()}
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
<Button size="SM" theme="light" text={m.cancel()} onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
@ -365,7 +365,7 @@ function SuccessModal({
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text={m.close()} onClick={onClose} />
</div>
</div>
);

View File

@ -1,17 +1,17 @@
import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button";
import Checkbox from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog";
import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem";
import { Button } from "../components/Button";
import Checkbox from "../components/Checkbox";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { TextAreaWithLabel } from "../components/TextArea";
import { useSettingsStore } from "../hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import { isOnDevice } from "../main";
import notifications from "../notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { TextAreaWithLabel } from "@components/TextArea";
import { isOnDevice } from "@/main";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc();
@ -65,7 +65,9 @@ export default function SettingsAdvancedRoute() {
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
enabled
? m.advanced_error_usb_emulation_enable({error: resp.error.data || m.unknown_error()})
: m.advanced_error_usb_emulation_disable({error: resp.error.data || m.unknown_error()})
);
return;
}
@ -80,11 +82,11 @@ export default function SettingsAdvancedRoute() {
send("resetConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
m.advanced_error_reset_config({error: resp.error.data || m.unknown_error()})
);
return;
}
notifications.success("Configuration reset to default successfully");
notifications.success(m.advanced_success_reset_config());
});
}, [send]);
@ -92,11 +94,11 @@ export default function SettingsAdvancedRoute() {
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
m.advanced_error_update_ssh_key({error: resp.error.data || m.unknown_error()})
);
return;
}
notifications.success("SSH key updated successfully");
notifications.success(m.advanced_success_update_ssh_key());
});
}, [send, sshKey]);
@ -105,7 +107,7 @@ export default function SettingsAdvancedRoute() {
send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
m.advanced_error_set_dev_mode({error: resp.error.data || m.unknown_error()})
);
return;
}
@ -120,7 +122,7 @@ export default function SettingsAdvancedRoute() {
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
m.advanced_error_set_dev_channel({error: resp.error.data || m.unknown_error()})
);
return;
}
@ -135,19 +137,17 @@ export default function SettingsAdvancedRoute() {
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
enabled
? m.advanced_error_loopback_enable({error: resp.error.data || m.unknown_error()})
: m.advanced_error_loopback_disable({error: resp.error.data || m.unknown_error()})
);
return;
}
setLocalLoopbackOnly(enabled);
if (enabled) {
notifications.success(
"Loopback-only mode enabled. Restart your device to apply.",
);
notifications.success(m.advanced_success_loopback_enabled());
} else {
notifications.success(
"Loopback-only mode disabled. Restart your device to apply.",
);
notifications.success(m.advanced_success_loopback_disabled());
}
});
},
@ -175,14 +175,14 @@ export default function SettingsAdvancedRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
title={m.advanced_title()}
description={m.advanced_description()}
/>
<div className="space-y-4">
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
title={m.advanced_dev_channel_title()}
description={m.advanced_dev_channel_description()}
>
<Checkbox
checked={devChannel}
@ -192,8 +192,8 @@ export default function SettingsAdvancedRoute() {
/>
</SettingsItem>
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
title={m.advanced_developer_mode_title()}
description={m.advanced_developer_mode_description()}
>
<Checkbox
checked={settings.developerMode}
@ -219,18 +219,17 @@ export default function SettingsAdvancedRoute() {
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Developer Mode Enabled
{m.advanced_developer_mode_enabled_title()}
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>Security is weakened while active</li>
<li>Only use if you understand the risks</li>
<li>{m.advanced_developer_mode_warning_security()}</li>
<li>{m.advanced_developer_mode_warning_risks()}</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
For advanced users only. Not for production use.
{m.advanced_developer_mode_warning_advanced()}
</div>
</div>
</div>
@ -238,8 +237,8 @@ export default function SettingsAdvancedRoute() {
)}
<SettingsItem
title="Loopback-Only Mode"
description="Restrict web interface access to localhost only (127.0.0.1)"
title={m.advanced_loopback_only_title()}
description={m.advanced_loopback_only_description()}
>
<Checkbox
checked={localLoopbackOnly}
@ -250,25 +249,25 @@ export default function SettingsAdvancedRoute() {
{isOnDevice && settings.developerMode && (
<div className="space-y-4">
<SettingsItem
title="SSH Access"
description="Add your SSH public key to enable secure remote access to the device"
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder="Enter your SSH public key"
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div>
@ -277,8 +276,8 @@ export default function SettingsAdvancedRoute() {
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
title={m.advanced_troubleshooting_mode_title()}
description={m.advanced_troubleshooting_mode_description()}
>
<Checkbox
defaultChecked={settings.debugMode}
@ -291,27 +290,27 @@ export default function SettingsAdvancedRoute() {
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
title={m.advanced_usb_emulation_title()}
description={m.advanced_usb_emulation_description()}
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
usbEmulationEnabled ? m.advanced_disable_usb_emulation() : m.advanced_enable_usb_emulation()
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
<SettingsItem
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
title={m.advanced_reset_config_title()}
description={m.advanced_reset_config_description()}
>
<Button
size="SM"
theme="light"
text="Reset Config"
text={m.advanced_reset_config_button()}
onClick={() => {
handleResetConfig();
window.location.reload();
@ -327,22 +326,23 @@ export default function SettingsAdvancedRoute() {
onClose={() => {
setShowLoopbackWarning(false);
}}
title="Enable Loopback-Only Mode?"
title={m.advanced_loopback_warning_title()}
description={
<>
<p>
WARNING: This will restrict web interface access to localhost (127.0.0.1)
only.
{m.advanced_loopback_warning_description()}
</p>
<p>
{m.advanced_loopback_warning_before()}
</p>
<p>Before enabling this feature, make sure you have either:</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>SSH access configured and tested</li>
<li>Cloud access enabled and working</li>
<li>{m.advanced_loopback_warning_ssh()}</li>
<li>{m.advanced_loopback_warning_cloud()}</li>
</ul>
</>
}
variant="warning"
confirmText="I Understand, Enable Anyway"
confirmText={m.advanced_loopback_warning_confirm()}
onConfirm={confirmLoopbackModeEnable}
/>
</div>

View File

@ -1,9 +1,9 @@
import { useCallback, useState } from "react";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { m } from "@localizations/messages.js";
export default function SettingsAppearanceRoute() {
const [currentTheme, setCurrentTheme] = useState(() => {
@ -28,22 +28,24 @@ export default function SettingsAppearanceRoute() {
}
}, []);
const themeOptions = [
{ value: "system", label: m.appearance_theme_system() },
{ value: "light", label: m.appearance_theme_light() },
{ value: "dark", label: m.appearance_theme_dark() },
];
return (
<div className="space-y-4">
<SettingsPageHeader
title="Appearance"
description="Customize the look and feel of your JetKVM interface"
title={m.appearance_title()}
description={m.appearance_page_description()}
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SettingsItem title={m.appearance_theme()} description={m.appearance_description()}>
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
options={themeOptions}
onChange={e => {
setCurrentTheme(e.target.value);
handleThemeChange(e.target.value);

View File

@ -1,16 +1,14 @@
import { useState, useEffect } from "react";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { useDeviceStore } from "@hooks/stores";
import { Button } from "@components/Button";
import Checkbox from "@components/Checkbox";
import { SettingsItem } from "@components/SettingsItem";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { Button } from "../components/Button";
import notifications from "../notifications";
import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { useDeviceStore } from "../hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export default function SettingsGeneralRoute() {
const { send } = useJsonRpc();
@ -34,7 +32,7 @@ export default function SettingsGeneralRoute() {
send("setAutoUpdateState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
m.general_auto_update_error({ error: resp.error.data || m.unknown_error() }),
);
return;
}
@ -45,44 +43,36 @@ export default function SettingsGeneralRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="General"
description="Configure device settings and update preferences"
title={m.general_title()}
description={m.general_page_description()}
/>
<div className="space-y-4">
<div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Check for Updates"
title={m.general_check_for_updates()}
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
{m.general_app_version({ version: currentVersions ? currentVersions.appVersion : m.loading() })}
<br />
System: {currentVersions.systemVersion}
{m.general_system_version({ version: currentVersions ? currentVersions.systemVersion : m.loading() })}
</>
) : (
<>
App: Loading...
<br />
System: Loading...
</>
)
}
/>
<div>
<Button
size="SM"
theme="light"
text="Check for Updates"
text={m.general_check_for_updates()}
onClick={() => navigateTo("./update")}
/>
</div>
</div>
<div className="space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
title={m.general_auto_update_title()}
description={m.general_auto_update_description()}
>
<Checkbox
checked={autoUpdate}
@ -95,14 +85,14 @@ export default function SettingsGeneralRoute() {
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Reboot Device"
description="Power cycle the JetKVM"
title={m.general_reboot_device()}
description={m.general_reboot_device_description()}
/>
<div>
<Button
size="SM"
theme="light"
text="Reboot Device"
text={m.general_reboot_device()}
onClick={() => navigateTo("./reboot")}
/>
</div>

View File

@ -1,8 +1,9 @@
import { useNavigate } from "react-router";
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button";
import { m } from "@localizations/messages.js";
export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate();
@ -50,15 +51,15 @@ function ConfirmationBox({
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Reboot JetKVM
{m.general_reboot_title()}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Do you want to proceed with rebooting the system?
{m.general_reboot_description()}
</p>
<div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Yes" onClick={onYes} />
<Button size="SM" theme="blank" text="No" onClick={onNo} />
<Button size="SM" theme="light" text={m.general_reboot_yes_button()} onClick={onYes} />
<Button size="SM" theme="blank" text={m.general_reboot_no_button()} onClick={onNo} />
</div>
</div>
</div>

View File

@ -1,14 +1,15 @@
import { useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { SystemVersionInfo, useVersion } from "@hooks/useVersion";
import { Button } from "@components/Button";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { SystemVersionInfo, useVersion } from "@/hooks/useVersion";
import Card from "@components/Card";
import LoadingSpinner from "@components/LoadingSpinner";
import { m } from "@localizations/messages.js";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
@ -41,8 +42,6 @@ export default function SettingsGeneralUpdateRoute() {
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({
onClose,
onConfirmUpdate,
@ -71,11 +70,6 @@ export function Dialog({
[setModalView],
);
// Reset modal view when dialog is opened
useEffect(() => {
setVersionInfo(null);
}, [setModalView]);
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
@ -133,8 +127,6 @@ function LoadingState({
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setProgressWidth("0%");
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
@ -169,10 +161,10 @@ function LoadingState({
<div className="space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
{m.general_update_checking_title()}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
We{"'"}re ensuring your device has the latest features and improvements.
{m.general_update_checking_description()}
</p>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300">
@ -183,7 +175,7 @@ function LoadingState({
></div>
</div>
<div className="mt-4">
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
<Button size="SM" theme="light" text={m.cancel()} onClick={onCancelCheck} />
</div>
</div>
</div>
@ -237,16 +229,18 @@ function UpdatingDeviceState({
const verfiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
const update_type = () => (type === "system" ? m.general_update_system_type() : m.general_update_app_type());
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
return m.general_update_status_fetching();
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
return m.general_update_status_downloading({ update_type });
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
return m.general_update_status_verifying({ update_type });
} else if (!updatedAt) {
return `Installing ${type} update...`;
return m.general_update_status_installing({ update_type });
} else {
return `Awaiting reboot`;
return m.general_update_status_awaiting_reboot();
}
};
@ -269,10 +263,10 @@ function UpdatingDeviceState({
<div className="w-full max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Updating your device
{m.general_update_updating_title()}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Please don{"'"}t turn off your device. This process may take a few minutes.
{m.general_update_updating_description()}
</p>
</div>
<Card className="space-y-4 p-4">
@ -281,7 +275,7 @@ function UpdatingDeviceState({
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white">
Rebooting to complete the update...
{m.general_update_rebooting()}
</span>
</div>
</div>
@ -297,7 +291,7 @@ function UpdatingDeviceState({
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
Linux System Update
{m.general_update_system_update_title()}
</p>
{calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
@ -329,7 +323,7 @@ function UpdatingDeviceState({
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
App Update
{m.general_update_app_update_title()}
</p>
{calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
@ -361,7 +355,7 @@ function UpdatingDeviceState({
<Button
size="XS"
theme="light"
text="Update in Background"
text={m.general_update_background_button()}
onClick={onMinimizeUpgradeDialog}
/>
</div>
@ -381,15 +375,15 @@ function SystemUpToDateState({
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
System is up to date
{m.general_update_up_to_date_title()}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Your system is running the latest version. No updates are currently available.
{m.general_update_up_to_date_description()}
</p>
<div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
<Button size="SM" theme="blank" text="Back" onClick={onClose} />
<Button size="SM" theme="light" text={m.general_update_check_again_button()} onClick={checkUpdate} />
<Button size="SM" theme="blank" text={m.general_update_back_button()} onClick={onClose} />
</div>
</div>
</div>
@ -409,30 +403,27 @@ function UpdateAvailableState({
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Update available
{m.general_update_available_title()}
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
A new update is available to enhance system performance and improve
compatibility. We recommend updating to ensure everything runs smoothly.
{m.general_update_available_description()}
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemUpdateAvailable ? (
<>
<span className="font-semibold">System:</span>{" "}
{versionInfo?.remote?.systemVersion}
<span className="font-semibold">{m.general_update_system_type()}</span>:&nbsp;{versionInfo?.remote?.systemVersion}
<br />
</>
) : null}
{versionInfo?.appUpdateAvailable ? (
<>
<span className="font-semibold">App:</span>{" "}
{versionInfo?.remote?.appVersion}
<span className="font-semibold">{m.general_update_application_type()}</span>:&nbsp;{versionInfo?.remote?.appVersion}
</>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
<Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
</div>
</div>
</div>
@ -444,14 +435,13 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Update Completed Successfully
{m.general_update_completed_title()}
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Your device has been successfully updated to the latest version. Enjoy the new
features and improvements!
{m.general_update_completed_description()}
</p>
<div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Back" onClick={onClose} />
<Button size="SM" theme="primary" text={m.back()} onClick={onClose} />
</div>
</div>
</div>
@ -470,18 +460,18 @@ function UpdateErrorState({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="text-base font-semibold dark:text-white">{m.general_update_error_title()}</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
An error occurred while updating your device. Please try again later.
{m.general_update_error_description()}
</p>
{errorMessage && (
<p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400">
Error details: {errorMessage}
{m.general_update_error_details({ errorMessage })}
</p>
)}
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
<Button size="SM" theme="light" text={m.back()} onClick={onClose} />
<Button size="SM" theme="blank" text={m.retry()} onClick={onRetryUpdate} />
</div>
</div>
</div>

View File

@ -1,20 +1,20 @@
import { useEffect } from "react";
import { BacklightSettings, useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { FeatureFlag } from "@components/FeatureFlag";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag";
import { UsbInfoSetting } from "@components/UsbInfoSetting";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export default function SettingsHardwareRoute() {
const { send } = useJsonRpc();
const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore();
const { displayRotation, setDisplayRotation } = useSettingsStore();
const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation);
@ -22,18 +22,18 @@ export default function SettingsHardwareRoute() {
};
const handleDisplayRotationSave = () => {
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => {
send("setDisplayRotation", { params: { rotation: displayRotation } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
m.hardware_display_orientation_error({ error: resp.error.data || m.unknown_error() }),
);
return;
}
notifications.success("Display orientation updated successfully");
notifications.success(m.hardware_display_orientation_success());
});
};
const { setBacklightSettings } = useSettingsStore();
const { backlightSettings, setBacklightSettings } = useSettingsStore();
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
// If the user has set the display to dim after it turns off, set the dim_after
@ -47,22 +47,37 @@ export default function SettingsHardwareRoute() {
};
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => {
send("setBacklightSettings", { params: backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
m.hardware_backlight_settings_error({ error: resp.error.data || m.unknown_error() }),
);
return;
}
notifications.success("Backlight settings updated successfully");
notifications.success(m.hardware_backlight_settings_success());
});
};
const handleBacklightMaxBrightnessChange = (max_brightness: number) => {
const settings = { ...backlightSettings, max_brightness };
handleBacklightSettingsChange(settings);
};
const handleBacklightDimAfterChange = (dim_after: number) => {
const settings = { ...backlightSettings, dim_after };
handleBacklightSettingsChange(settings);
};
const handleBacklightOffAfterChange = (off_after: number) => {
const settings = { ...backlightSettings, off_after };
handleBacklightSettingsChange(settings);
};
useEffect(() => {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
m.hardware_backlight_settings_get_error({ error: resp.error.data || m.unknown_error() }),
);
}
const result = resp.result as BacklightSettings;
@ -73,97 +88,93 @@ export default function SettingsHardwareRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
title={m.hardware_title()}
description={m.hardware_page_description()}
/>
<div className="space-y-4">
<SettingsItem
title="Display Orientation"
description="Set the orientation of the display"
title={m.hardware_display_orientation_title()}
description={m.hardware_display_orientation_description()}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.displayRotation.toString()}
options={[
{ value: "270", label: "Normal" },
{ value: "90", label: "Inverted" },
{ value: "270", label: m.hardware_display_orientation_normal() },
{ value: "90", label: m.hardware_display_orientation_inverted() },
]}
onChange={e => {
settings.displayRotation = e.target.value;
handleDisplayRotationChange(settings.displayRotation);
handleDisplayRotationChange(e.target.value);
}}
/>
</SettingsItem>
<SettingsItem
title="Display Brightness"
description="Set the brightness of the display"
title={m.hardware_display_brightness_title()}
description={m.hardware_display_brightness_description()}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
value={backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
{ value: "0", label: m.hardware_display_brightness_off() },
{ value: "10", label: m.hardware_display_brightness_low() },
{ value: "35", label: m.hardware_display_brightness_medium() },
{ value: "64", label: m.hardware_display_brightness_high() },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
handleBacklightMaxBrightnessChange(parseInt(e.target.value));
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
{backlightSettings.max_brightness != 0 && (
<>
<SettingsItem
title="Dim Display After"
description="Set how long to wait before dimming the display"
title={m.hardware_dim_display_after_title()}
description={m.hardware_dim_display_after_description()}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
value={backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
{ value: "0", label: m.hardware_time_never() },
{ value: "60", label: m.hardware_time_1_minute() },
{ value: "300", label: m.hardware_time_5_minutes() },
{ value: "600", label: m.hardware_time_10_minutes() },
{ value: "1800", label: m.hardware_time_30_minutes() },
{ value: "3600", label: m.hardware_time_1_hour() },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
handleBacklightDimAfterChange(parseInt(e.target.value));
}}
/>
</SettingsItem>
<SettingsItem
title="Turn off Display After"
description="Period of inactivity before display automatically turns off"
title={m.hardware_turn_off_display_after_title()}
description={m.hardware_turn_off_display_after_description()}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
value={backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
{ value: "0", label: m.hardware_time_never() },
{ value: "300", label: m.hardware_time_5_minutes() },
{ value: "600", label: m.hardware_time_10_minutes() },
{ value: "1800", label: m.hardware_time_30_minutes() },
{ value: "3600", label: m.hardware_time_1_hour() },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
handleBacklightOffAfterChange(parseInt(e.target.value));
}}
/>
</SettingsItem>
</>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
{m.hardware_display_wake_up_note()}
</p>
</div>

View File

@ -1,13 +1,14 @@
import { useCallback, useEffect } from "react";
import { useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import useKeyboardLayout from "@hooks/useKeyboardLayout";
import { Checkbox } from "@components/Checkbox";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Checkbox } from "@/components/Checkbox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export default function SettingsKeyboardRoute() {
const { setKeyboardLayout } = useSettingsStore();
@ -33,10 +34,10 @@ export default function SettingsKeyboardRoute() {
send("setKeyboardLayout", { layout: isoCode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`,
m.keyboard_layout_error({ error: resp.error.data || m.unknown_error() }),
);
}
notifications.success("Keyboard layout set successfully to " + isoCode);
notifications.success(m.keyboard_layout_success({ layout: isoCode }));
setKeyboardLayout(isoCode);
});
},
@ -46,14 +47,14 @@ export default function SettingsKeyboardRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Keyboard"
description="Configure keyboard settings for your device"
title={m.keyboard_title()}
description={m.keyboard_description()}
/>
<div className="space-y-4">
<SettingsItem
title="Keyboard Layout"
description="Keyboard layout of target operating system"
title={m.keyboard_layout_title()}
description={m.keyboard_layout_description()}
>
<SelectMenuBasic
size="SM"
@ -65,14 +66,14 @@ export default function SettingsKeyboardRoute() {
/>
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
{m.keyboard_layout_long_description()}
</p>
</div>
<div className="space-y-4">
<SettingsItem
title="Show Pressed Keys"
description="Display currently pressed keys in the status bar"
title={m.keyboard_show_pressed_keys_title()}
description={m.keyboard_show_pressed_keys_description()}
>
<Checkbox
checked={showPressedKeys}

View File

@ -1,24 +1,19 @@
import { useNavigate } from "react-router";
import { useState } from "react";
import { useNavigate } from "react-router";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import { KeySequence, useMacrosStore, generateMacroId } from "@hooks/stores";
import { MacroForm } from "@components/MacroForm";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { DEFAULT_DELAY } from "@/constants/macros";
import notifications from "@/notifications";
import { normalizeSortOrders } from "@/utils";
import { m } from "@localizations/messages.js";
export default function SettingsMacrosAddRoute() {
const { macros, saveMacros } = useMacrosStore();
const [isSaving, setIsSaving] = useState(false);
const navigate = useNavigate();
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
const handleAddMacro = async (macro: Partial<KeySequence>) => {
setIsSaving(true);
try {
@ -30,13 +25,13 @@ export default function SettingsMacrosAddRoute() {
};
await saveMacros(normalizeSortOrders([...macros, newMacro]));
notifications.success(`Macro "${newMacro.name}" created successfully`);
notifications.success(m.macros_created_success({name: newMacro.name}));
navigate("../");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to create macro: ${error.message}`);
notifications.error(m.macros_failed_create_error({error: error.message || m.unknown_error() }));
} else {
notifications.error("Failed to create macro");
notifications.error(m.macros_failed_create());
}
} finally {
setIsSaving(false);
@ -46,8 +41,8 @@ export default function SettingsMacrosAddRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Add New Macro"
description="Create a new keyboard macro"
title={m.macros_add_new()}
description={m.macros_add_description()}
/>
<MacroForm
initialData={{

View File

@ -1,20 +1,15 @@
import { useNavigate, useParams } from "react-router";
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { LuTrash2 } from "react-icons/lu";
import { KeySequence, useMacrosStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import { KeySequence, useMacrosStore } from "@hooks/stores";
import { Button } from "@components/Button";
import { ConfirmDialog } from "@components/ConfirmDialog";
import { MacroForm } from "@components/MacroForm";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
import { normalizeSortOrders } from "@/utils";
import { m } from "@localizations/messages.js";
export default function SettingsMacrosEditRoute() {
const { macros, saveMacros } = useMacrosStore();
@ -56,13 +51,13 @@ export default function SettingsMacrosEditRoute() {
);
await saveMacros(normalizeSortOrders(newMacros));
notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
notifications.success(m.macros_updated_success({ name: updatedMacro.name }));
navigate("../");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to update macro: ${error.message}`);
notifications.error(m.macros_failed_update({ error: error.message }));
} else {
notifications.error("Failed to update macro");
notifications.error(m.macros_failed_update());
}
} finally {
setIsUpdating(false);
@ -76,13 +71,13 @@ export default function SettingsMacrosEditRoute() {
try {
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
await saveMacros(updatedMacros);
notifications.success(`Macro "${macro.name}" deleted successfully`);
notifications.success(m.macros_deleted_success({ name: macro.name }));
navigate("../macros");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`);
notifications.error(m.macros_failed_delete_error({ error: error.message }));
} else {
notifications.error("Failed to delete macro");
notifications.error(m.macros_failed_delete());
}
} finally {
setIsDeleting(false);
@ -95,13 +90,13 @@ export default function SettingsMacrosEditRoute() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<SettingsPageHeader
title="Edit Macro"
description="Modify your keyboard macro"
title={m.macros_edit_title()}
description={m.macros_edit_description()}
/>
<Button
size="SM"
theme="light"
text="Delete Macro"
className="text-red-500 dark:text-red-400"
LeadingIcon={LuTrash2}
onClick={() => setShowDeleteConfirm(true)}
@ -113,16 +108,15 @@ export default function SettingsMacrosEditRoute() {
onSubmit={handleUpdateMacro}
onCancel={() => navigate("../")}
isSubmitting={isUpdating}
submitText="Save Changes"
/>
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Macro"
description="Are you sure you want to delete this macro? This action cannot be undone."
title={m.macros_delete_macro()}
description={m.macros_delete_confirm()}
variant="danger"
confirmText={isDeleting ? "Deleting" : "Delete"}
confirmText={isDeleting ? m.macros_deleting() : m.delete()}
onConfirm={() => {
handleDeleteMacro();
setShowDeleteConfirm(false);

View File

@ -11,23 +11,18 @@ import {
LuCommand,
} from "react-icons/lu";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { Button } from "@/components/Button";
import EmptyCard from "@/components/EmptyCard";
import Card from "@/components/Card";
import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros";
import { KeySequence, useMacrosStore, generateMacroId } from "@hooks/stores";
import useKeyboardLayout from "@hooks/useKeyboardLayout";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ConfirmDialog } from "@components/ConfirmDialog";
import EmptyCard from "@components/EmptyCard";
import LoadingSpinner from "@components/LoadingSpinner";
import notifications from "@/notifications";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import LoadingSpinner from "@/components/LoadingSpinner";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
import { normalizeSortOrders } from "@/utils";
import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros";
import { m } from "@localizations/messages.js";
export default function SettingsMacrosRoute() {
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
@ -51,12 +46,12 @@ export default function SettingsMacrosRoute() {
const handleDuplicateMacro = useCallback(
async (macro: KeySequence) => {
if (!macro?.id || !macro?.name) {
notifications.error("Invalid macro data");
notifications.error(m.macros_invalid_data());
return;
}
if (isMaxMacrosReached) {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
notifications.error(m.macros_maximum_macros_reached({ maximum: MAX_TOTAL_MACROS }));
return;
}
@ -71,12 +66,12 @@ export default function SettingsMacrosRoute() {
try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
notifications.success(m.macros_duplicated_success({ name: newMacroCopy.name }));
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to duplicate macro: ${error.message}`);
notifications.error(m.macros_failed_duplicate_error({ error: error.message || m.unknown_error() }));
} else {
notifications.error("Failed to duplicate macro");
notifications.error(m.macros_failed_duplicate());
}
} finally {
setActionLoadingId(null);
@ -88,7 +83,7 @@ export default function SettingsMacrosRoute() {
const handleMoveMacro = useCallback(
async (index: number, direction: "up" | "down", macroId: string) => {
if (!Array.isArray(macros) || macros.length === 0) {
notifications.error("No macros available");
notifications.error(m.macros_no_macros_available());
return;
}
@ -103,12 +98,12 @@ export default function SettingsMacrosRoute() {
const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
notifications.success(m.macros_order_updated());
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`);
notifications.error(m.macros_failed_reorder_error({ error: error.message || m.unknown_error() }));
} else {
notifications.error("Failed to reorder macros");
notifications.error(m.macros_failed_reorder());
}
} finally {
setActionLoadingId(null);
@ -126,14 +121,14 @@ export default function SettingsMacrosRoute() {
macros.filter(m => m.id !== macroToDelete.id),
);
await saveMacros(updatedMacros);
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
notifications.success(m.macros_deleted_success({ name: macroToDelete.name }));
setShowDeleteConfirm(false);
setMacroToDelete(null);
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`);
notifications.error(m.macros_failed_delete_error({ error: error.message || m.unknown_error() }));
} else {
notifications.error("Failed to delete macro");
notifications.error(m.macros_failed_delete());
}
} finally {
setActionLoadingId(null);
@ -153,7 +148,7 @@ export default function SettingsMacrosRoute() {
onClick={() => handleMoveMacro(index, "up", macro.id)}
disabled={index === 0 || actionLoadingId === macro.id}
LeadingIcon={LuArrowUp}
aria-label={`Move ${macro.name} up`}
aria-label={m.macros_aria_move_up({ name: macro.name })}
/>
<Button
size="XS"
@ -161,7 +156,7 @@ export default function SettingsMacrosRoute() {
onClick={() => handleMoveMacro(index, "down", macro.id)}
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
LeadingIcon={LuArrowDown}
aria-label={`Move ${macro.name} down`}
aria-label={m.macros_aria_move_down({ name: macro.name })}
/>
</div>
@ -189,10 +184,7 @@ export default function SettingsMacrosRoute() {
{selectedKeyboard.modifierDisplayMap[modifier] || modifier}
</span>
{idx < step.modifiers.length - 1 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
<span className="text-slate-400 dark:text-slate-600">&nbsp;+&nbsp;</span>
)}
</Fragment>
))}
@ -201,10 +193,7 @@ export default function SettingsMacrosRoute() {
step.modifiers.length > 0 &&
Array.isArray(step.keys) &&
step.keys.length > 0 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
<span className="text-slate-400 dark:text-slate-600">&nbsp;+&nbsp;</span>
)}
{Array.isArray(step.keys) &&
@ -214,17 +203,14 @@ export default function SettingsMacrosRoute() {
{selectedKeyboard.keyDisplayMap[key] || key}
</span>
{idx < step.keys.length - 1 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
<span className="text-slate-400 dark:text-slate-600">&nbsp;+&nbsp;</span>
)}
</Fragment>
))}
</>
) : (
<span className="font-medium text-slate-500 dark:text-slate-400">
Delay only
{m.macros_delay_only()}
</span>
)}
{step.delay !== DEFAULT_DELAY && (
@ -251,7 +237,7 @@ export default function SettingsMacrosRoute() {
setShowDeleteConfirm(true);
}}
disabled={actionLoadingId === macro.id}
aria-label={`Delete macro ${macro.name}`}
aria-label={m.macros_aria_delete({ name: macro.name })}
/>
<Button
size="XS"
@ -259,16 +245,16 @@ export default function SettingsMacrosRoute() {
LeadingIcon={LuCopy}
onClick={() => handleDuplicateMacro(macro)}
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
aria-label={m.macros_aria_duplicate({ name: macro.name })}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuPenLine}
text="Edit"
text={m.macros_edit_button()}
onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`}
aria-label={m.macros_aria_edit({ name: macro.name })}
/>
</div>
</div>
@ -281,10 +267,10 @@ export default function SettingsMacrosRoute() {
setShowDeleteConfirm(false);
setMacroToDelete(null);
}}
title="Delete Macro"
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
title={m.macros_confirm_delete_title()}
description={m.macros_confirm_delete_description({ name: macroToDelete?.name || "" })}
variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
confirmText={actionLoadingId === macroToDelete?.id ? m.macros_confirm_deleting() : m.macros_delete_confirm_button()}
onConfirm={handleDeleteMacro}
isConfirming={actionLoadingId === macroToDelete?.id}
/>
@ -309,18 +295,18 @@ export default function SettingsMacrosRoute() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<SettingsPageHeader
title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`}
title={m.macros_title()}
description={m.macros_add_new()}
/>
{macros.length > 0 && (
<div className="flex items-center pl-2">
<Button
size="SM"
theme="primary"
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
text={isMaxMacrosReached ? m.macros_max_reached() : m.macros_add_new_macro()}
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
aria-label={m.macros_aria_add_new()}
/>
</div>
)}
@ -330,7 +316,7 @@ export default function SettingsMacrosRoute() {
{loading && macros.length === 0 ? (
<EmptyCard
IconElm={LuCommand}
headline="Loading macros..."
headline={m.macros_loading()}
BtnElm={
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
@ -340,16 +326,16 @@ export default function SettingsMacrosRoute() {
) : macros.length === 0 ? (
<EmptyCard
IconElm={LuCommand}
headline="Create Your First Macro"
description="Combine keystrokes into a single action"
headline={m.macros_create_first_headline()}
description={m.macros_create_first_description()}
BtnElm={
<Button
size="SM"
theme="primary"
text="Add New Macro"
text={m.macros_add_new_macro()}
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
aria-label={m.macros_aria_add_new()}
/>
}
/>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { LuEthernetPort } from "react-icons/lu";
@ -12,23 +12,22 @@ import {
NetworkState,
TimeSyncMode,
useNetworkStateStore,
} from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
} from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import AutoHeight from "@components/AutoHeight";
import { Button } from "@components/Button";
import { ConfirmDialog } from "@components/ConfirmDialog";
import EmptyCard from "@components/EmptyCard";
import Fieldset from "@components/Fieldset";
import { GridCard } from "@components/Card";
import InputField, { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import Fieldset from "@/components/Fieldset";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import Ipv6NetworkCard from "@components/Ipv6NetworkCard";
import DhcpLeaseCard from "@components/DhcpLeaseCard";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
import EmptyCard from "../components/EmptyCard";
import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard";
dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = {
@ -46,14 +45,18 @@ const defaultNetworkSettings: NetworkSettings = {
export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
const [remaining, setRemaining] = useState<string | null>(null);
useEffect(() => {
const updateRemaining = useCallback(() => {
setRemaining(dayjs(lifetime).fromNow());
}, [lifetime]);
useEffect(() => {
setTimeout(() => updateRemaining(), 0);
const interval = setInterval(() => {
setRemaining(dayjs(lifetime).fromNow());
updateRemaining();
}, 1000 * 30);
return () => clearInterval(interval);
}, [lifetime]);
}, [updateRemaining]);
if (lifetime == "") {
return <strong>N/A</strong>;
@ -81,24 +84,19 @@ export default function SettingsNetworkRoute() {
useState<NetworkSettings>(defaultNetworkSettings);
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
const [firstNetworkSettings, setFirstNetworkSettings] = useState<NetworkSettings | undefined>(undefined);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const [customDomain, setCustomDomain] = useState<string>("");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
useEffect(() => {
if (networkSettings.domain && networkSettingsLoaded) {
// Check if the domain is one of the predefined options
const selectedDomainOption = useMemo(() => {
if (!networkSettingsLoaded) return "dhcp";
const predefinedOptions = ["dhcp", "local"];
if (predefinedOptions.includes(networkSettings.domain)) {
setSelectedDomainOption(networkSettings.domain);
} else {
setSelectedDomainOption("custom");
setCustomDomain(networkSettings.domain);
}
}
return predefinedOptions.includes(networkSettings.domain) ? networkSettings.domain : "custom";
}, [networkSettings.domain, networkSettingsLoaded]);
const customDomain = useMemo(() => {
if (!networkSettingsLoaded) return "";
const predefinedOptions = ["dhcp", "local"];
return predefinedOptions.includes(networkSettings.domain) ? "" : networkSettings.domain;
}, [networkSettings.domain, networkSettingsLoaded]);
const getNetworkSettings = useCallback(() => {
@ -109,12 +107,12 @@ export default function SettingsNetworkRoute() {
console.debug("Network settings: ", networkSettings);
setNetworkSettings(networkSettings);
if (!firstNetworkSettings.current) {
firstNetworkSettings.current = networkSettings;
if (!firstNetworkSettings) {
setFirstNetworkSettings(networkSettings);
}
setNetworkSettingsLoaded(true);
});
}, [send]);
}, [send, firstNetworkSettings]);
const getNetworkState = useCallback(() => {
send("getNetworkState", {}, (resp: JsonRpcResponse) => {
@ -138,8 +136,7 @@ export default function SettingsNetworkRoute() {
return;
}
const networkSettings = resp.result as NetworkSettings;
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
firstNetworkSettings.current = networkSettings;
setFirstNetworkSettings(networkSettings);
setNetworkSettings(networkSettings);
getNetworkState();
setNetworkSettingsLoaded(true);
@ -160,8 +157,10 @@ export default function SettingsNetworkRoute() {
}, [send]);
useEffect(() => {
setTimeout(() => {
getNetworkState();
getNetworkSettings();
}, 0);
}, [getNetworkState, getNetworkSettings]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
@ -197,14 +196,12 @@ export default function SettingsNetworkRoute() {
};
const handleDomainOptionChange = (value: string) => {
setSelectedDomainOption(value);
if (value !== "custom") {
handleDomainChange(value);
}
};
const handleCustomDomainChange = (value: string) => {
setCustomDomain(value);
handleDomainChange(value);
};
@ -309,7 +306,6 @@ export default function SettingsNetworkRoute() {
placeholder="home"
value={customDomain}
onChange={e => {
setCustomDomain(e.target.value);
handleCustomDomainChange(e.target.value);
}}
/>
@ -361,7 +357,7 @@ export default function SettingsNetworkRoute() {
<Button
size="SM"
theme="primary"
disabled={firstNetworkSettings.current === networkSettings}
disabled={firstNetworkSettings === networkSettings}
text="Save Settings"
onClick={() => setNetworkSettingsRemote(networkSettings)}
/>

View File

@ -50,7 +50,7 @@ export default function SettingsVideoRoute() {
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
const [edidLoading, setEdidLoading] = useState(false);
const [edidLoading, setEdidLoading] = useState(true);
const { debugMode } = useSettingsStore();
// Video enhancement settings from store
const {
@ -63,7 +63,6 @@ export default function SettingsVideoRoute() {
} = useSettingsStore();
useEffect(() => {
setEdidLoading(true);
send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));

View File

@ -9,9 +9,9 @@ import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { checkAuth } from "@/main";
import api from "@/api";
import { CLOUD_API } from "@/ui.config";
import api from "../api";
import { m } from "@localizations/messages.js";
const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
await checkAuth();
@ -31,12 +31,22 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
// Handle form submission
const { name, id, returnTo } = Object.fromEntries(await request.formData());
if (!name || name === "") {
return { message: m.register_device_no_name() };
}
try {
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, { name });
if (res.ok) {
return redirect(returnTo?.toString() ?? `/devices/${id}`);
} else {
return { error: "There was an error registering your device" };
return { error: m.register_device_error({ error:res.statusText }) };
}
} catch (e) {
console.error(e);
return { message: m.register_device_error({ error: String(e) }) };
}
};
@ -61,21 +71,19 @@ export default function SetupRoute() {
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Let&apos;s name your device</h1>
<p className="text-slate-600 dark:text-slate-400">
Name your device so you can easily identify it later. You can change
this name at any time.
{m.register_device_name_description()}
</p>
</div>
<Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4">
<InputFieldWithLabel
label="Device Name"
label={m.register_device_name_label()}
type="text"
name="name"
placeholder="Plex Media Server"
placeholder={m.register_device_name_placeholder()}
autoFocus
data-1p-ignore
autoComplete="organization"
error={action?.error?.toString()}
/>
@ -86,7 +94,7 @@ export default function SetupRoute() {
theme="primary"
fullWidth
type="submit"
text="Finish Setup"
text={m.register_device_finish_button()}
textAlign="center"
/>
</Form>

View File

@ -15,10 +15,10 @@ import { FocusTrap } from "focus-trap-react";
import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import { cx } from "@/cva.config";
import {
KeyboardLedState,
KeysDownState,
@ -33,23 +33,24 @@ import {
useUpdateStore,
useVideoStore,
VideoState,
} from "@/hooks/stores";
} from "@hooks/stores";
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { useVersion } from "@hooks/useVersion";
import WebRTCVideo from "@components/WebRTCVideo";
import DashboardNavbar from "@components/Header";
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
const ConnectionStatsSidebar = lazy(() => import('@components/sidebar/connectionStats'));
const Terminal = lazy(() => import('@components/Terminal'));
const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard"));
import Modal from "@/components/Modal";
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
const UpdateInProgressStatusCard = lazy(() => import("@components/UpdateInProgressStatusCard"));
import Modal from "@components/Modal";
import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay,
} from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
} from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { DeviceStatus } from "@routes/welcome-local";
import { useVersion } from "@/hooks/useVersion";
import { m } from "@localizations/messages.js";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
@ -145,7 +146,7 @@ export default function KvmIdRoute() {
const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const [loadingMessage, setLoadingMessage] = useState(m.connecting_to_device());
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
console.log("Closing peer connection");
@ -182,12 +183,12 @@ export default function KvmIdRoute() {
pc: RTCPeerConnection,
remoteDescription: RTCSessionDescriptionInit,
) {
setLoadingMessage("Setting remote description");
setLoadingMessage(m.setting_remote_description());
try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully");
setLoadingMessage("Establishing secure connection...");
setLoadingMessage(m.establishing_secure_connection());
} catch (error) {
console.error(
"[setRemoteSessionDescription] Failed to set remote description:",
@ -206,7 +207,7 @@ export default function KvmIdRoute() {
if (pc.sctp?.state === "connected") {
console.log("[setRemoteSessionDescription] Remote description set");
clearInterval(checkInterval);
setLoadingMessage("Connection established");
setLoadingMessage(m.connection_established());
} else if (attempts >= 10) {
console.warn(
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
@ -243,31 +244,32 @@ export default function KvmIdRoute() {
retryOnError: true,
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.debug("Reconnect stopped");
onReconnectStop: (numAttempts: number) => {
console.debug("Reconnect stopped", numAttempts);
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
shouldReconnect(event: WebSocketEventMap['close']) {
console.debug("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
},
onClose(event) {
onClose(event: WebSocketEventMap['close']) {
console.debug("[Websocket] onClose", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
onError(event: WebSocketEventMap['error']) {
console.error("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.debug("[Websocket] onOpen");
onOpen(event: WebSocketEventMap['open']) {
console.debug("[Websocket] onOpen", event);
},
onMessage: message => {
onMessage(event: WebSocketEventMap['message']) {
const message = event as MessageEvent;
if (message.data === "pong") return;
/*
@ -360,12 +362,12 @@ export default function KvmIdRoute() {
const sd = btoa(JSON.stringify(pc.localDescription));
// Legacy mode == UI in cloud with updated code connecting to older device version.
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
// In device mode, old devices wont serve this JS, and on newer devices legacy mode wont be enabled
const sessionUrl = `${CLOUD_API}/webrtc/session`;
console.log("Trying to get remote session description");
setLoadingMessage(
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
m.getting_remote_session_description({ attempt: signalingAttempts.current + 1 }),
);
const res = await api.POST(sessionUrl, {
sd,
@ -382,7 +384,7 @@ export default function KvmIdRoute() {
}
console.debug("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
setLoadingMessage(m.setting_remote_session_description());
const decodedSd = atob(json.sd);
const parsedSd = JSON.parse(decodedSd);
@ -394,12 +396,12 @@ export default function KvmIdRoute() {
const setupPeerConnection = useCallback(async () => {
console.debug("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
setLoadingMessage(m.connecting_to_device());
let pc: RTCPeerConnection;
try {
console.debug("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
setLoadingMessage(m.creating_peer_connection());
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers
@ -409,7 +411,7 @@ export default function KvmIdRoute() {
setPeerConnectionState(pc.connectionState);
console.debug("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
setLoadingMessage(m.setting_up_connection_to_device());
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
setTimeout(() => {
@ -459,7 +461,7 @@ export default function KvmIdRoute() {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.debug("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
setLoadingMessage(m.ice_gathering_completed());
if (isLegacySignalingEnabled.current) {
// We can now start the https/ws connection to get the remote session description from the KVM device
@ -467,7 +469,7 @@ export default function KvmIdRoute() {
}
} else if (pc.iceGatheringState === "gathering") {
console.debug("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
setLoadingMessage(m.gathering_ice_candidates());
}
};
@ -478,6 +480,8 @@ export default function KvmIdRoute() {
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = (e: Event) => console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel);
};
@ -601,9 +605,10 @@ export default function KvmIdRoute() {
const { setHdmiState } = useVideoStore();
const {
keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState,
keysDownState, setKeysDownState,
setUsbState,
} = useHidStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const { setHidRpcDisabled } = useRTCStore();
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@ -837,7 +842,7 @@ export default function KvmIdRoute() {
isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email}
picture={user?.picture}
kvmName={deviceName ?? "JetKVM Device"}
kvmName={deviceName ?? m.jetkvm_device()}
/>
<div className="relative flex h-full w-full overflow-hidden">
@ -873,11 +878,11 @@ export default function KvmIdRoute() {
</div>
{kvmTerminal && (
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
<Terminal type="kvm" dataChannel={kvmTerminal} title={m.kvm_terminal()} />
)}
{serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
<Terminal type="serial" dataChannel={serialConsole} title={m.serial_console()} />
)}
</FeatureFlagProvider>
);

View File

@ -2,6 +2,7 @@ import { LinkButton } from "@/components/Button";
import SimpleNavbar from "@/components/SimpleNavbar";
import Container from "@/components/Container";
import GridBackground from "@components/GridBackground";
import { m } from "@localizations/messages.js";
export default function DevicesAlreadyAdopted() {
return (
@ -14,15 +15,12 @@ export default function DevicesAlreadyAdopted() {
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
<div className="space-y-4 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Device Already Registered</h1>
<h1 className="text-4xl font-semibold text-black dark:text-white">{m.already_adopted_title()}</h1>
<p className="text-lg text-slate-600 dark:text-slate-400">
This device is currently registered to another user in our cloud
dashboard.
{m.already_adopted_other_user()}
</p>
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400">
If you&apos;re the new owner, please ask the previous owner to de-register
the device from their account in the cloud dashboard. If you believe
this is an error, contact our support team for assistance.
{m.already_adopted_new_owner()}
</p>
</div>
@ -31,7 +29,7 @@ export default function DevicesAlreadyAdopted() {
to="/devices"
size="LG"
theme="primary"
text="Return to Dashboard"
text={m.already_adopted_return_to_dashboard()}
/>
</div>
</div>

View File

@ -4,13 +4,14 @@ import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { useInterval } from "usehooks-ts";
import { User } from "@hooks/stores";
import DashboardNavbar from "@components/Header";
import EmptyCard from "@components/EmptyCard";
import KvmCard from "@components/KvmCard";
import { LinkButton } from "@components/Button";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import { CLOUD_API } from "@/ui.config";
import { m } from "@localizations/messages";
interface LoaderData {
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
@ -54,10 +55,10 @@ export default function DevicesRoute() {
<div className="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20">
<div>
<h1 className="text-xl font-bold text-black dark:text-white">
Cloud KVMs
{m.cloud_kvms()}
</h1>
<p className="text-base text-slate-700 dark:text-slate-400">
Manage your cloud KVMs and connect to them securely.
{m.cloud_kvms_description()}
</p>
</div>
</div>
@ -66,15 +67,15 @@ export default function DevicesRoute() {
<div className="max-w-3xl">
<EmptyCard
IconElm={LuMonitorSmartphone}
headline="No devices found"
description="You don't have any devices with enabled JetKVM Cloud yet."
headline={m.cloud_kvms_no_devices()}
description={m.cloud_kvms_no_devices_description()}
BtnElm={
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="primary"
TrailingIcon={ArrowRightIcon}
text="Learn more"
text={m.learn_more()}
/>
}
/>

View File

@ -1,22 +1,21 @@
import { useState } from "react";
import { Form, redirect, useActionData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import LogoBlueIcon from "@assets/logo-blue.png";
import LogoWhiteIcon from "@assets/logo-white.svg";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Container from "@components/Container";
import ExtLink from "@components/ExtLink";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import { InputFieldWithLabel } from "@components/InputField";
import SimpleNavbar from "@components/SimpleNavbar";
import { DeviceStatus } from "@routes/welcome-local";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
import ExtLink from "../components/ExtLink";
import { DeviceStatus } from "./welcome-local";
import api from "@/api";
import { m } from "@localizations/messages.js";
const loader: LoaderFunction = async () => {
const res = await api
@ -42,11 +41,11 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
if (response.ok) {
return redirect("/");
} else {
return { error: "Invalid password" };
return { error: m.invalid_password() };
}
} catch (error) {
console.error(error);
return { error: "An error occurred while logging in" };
return { error: m.login_error() };
}
};
@ -73,10 +72,10 @@ export default function LoginLocalRoute() {
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome back to JetKVM
{m.login_welcome_back()}
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Enter your password to access your JetKVM.
{m.login_enter_password_description()}
</p>
</div>
@ -84,11 +83,11 @@ export default function LoginLocalRoute() {
<Form method="POST" className="mx-auto max-w-sm space-y-4">
<div className="space-y-4">
<InputFieldWithLabel
label="Password"
label={m.login_password_label()}
type={showPassword ? "text" : "password"}
name="password"
autoComplete="current-password"
placeholder="Enter your password"
placeholder={m.login_enter_password()}
autoFocus
error={actionData?.error}
TrailingElm={
@ -116,7 +115,7 @@ export default function LoginLocalRoute() {
theme="primary"
fullWidth
type="submit"
text="Log In"
text={m.log_in()}
textAlign="center"
/>
@ -125,7 +124,7 @@ export default function LoginLocalRoute() {
href="https://jetkvm.com/docs/networking/local-access#reset-password"
className="hover:underline"
>
Forgot password?
{m.login_forgot_password()}
</ExtLink>
</div>
</Form>

View File

@ -1,5 +1,6 @@
import { useLocation, useSearchParams } from "react-router";
import { m } from "@localizations/messages.js";
import AuthLayout from "@components/AuthLayout";
export default function LoginRoute() {
@ -11,11 +12,11 @@ export default function LoginRoute() {
return (
<AuthLayout
showCounter={true}
title="Connect your JetKVM to the cloud"
description="Unlock remote access and advanced features for your device"
action="Log in & Connect device"
title={m.auth_connect_to_cloud()}
description={m.auth_connect_to_cloud_description()}
action={m.auth_connect_to_cloud_action()}
// Header CTA
cta="Don't have an account?"
cta={m.auth_header_cta_dont_have_account()}
ctaHref={`/signup?${sq.toString()}`}
/>
);
@ -23,11 +24,11 @@ export default function LoginRoute() {
return (
<AuthLayout
title="Log in to your JetKVM account"
description="Log in to access and manage your devices securely"
action="Log in"
title={m.auth_login()}
description={m.auth_login_description()}
action={m.auth_login_action()}
// Header CTA
cta="New to JetKVM?"
cta={m.auth_header_cta_new_to_jetkvm()}
ctaHref={`/signup?${sq.toString()}`}
/>
);

View File

@ -1,6 +1,7 @@
import { useLocation, useSearchParams } from "react-router";
import AuthLayout from "@components/AuthLayout";
import { m } from "@localizations/messages.js";
export default function SignupRoute() {
const [sq] = useSearchParams();
@ -11,10 +12,10 @@ export default function SignupRoute() {
return (
<AuthLayout
showCounter={true}
title="Connect your JetKVM to the cloud"
description="Unlock remote access and advanced features for your device."
action="Signup & Connect device"
cta="Already have an account?"
title={m.auth_connect_to_cloud()}
description={m.auth_connect_to_cloud_description()}
action={m.auth_signup_connect_to_cloud_action()}
cta={m.auth_header_cta_already_have_account()}
ctaHref={`/login?${sq.toString()}`}
/>
);
@ -22,11 +23,11 @@ export default function SignupRoute() {
return (
<AuthLayout
title="Create your JetKVM account"
description="Create your account and start managing your devices with ease."
action="Create Account"
title={m.auth_signup_create_account()}
description={m.auth_signup_create_account_description()}
action={m.auth_signup_create_account_action()}
// Header CTA
cta="Already have an account?"
cta={m.auth_header_cta_already_have_account()}
ctaHref={`/login?${sq.toString()}`}
/>
);

View File

@ -1,17 +1,17 @@
import { useState } from "react";
import { Form, redirect, useActionData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router";
import { useState } from "react";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import { cx } from "@/cva.config";
import LogoBlueIcon from "@assets/logo-blue.png";
import LogoWhiteIcon from "@assets/logo-white.svg";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Container from "@components/Container";
import GridBackground from "@components/GridBackground";
import { GridCard } from "@components/Card";
import { DEVICE_API } from "@/ui.config";
import { GridCard } from "../components/Card";
import { cx } from "../cva.config";
import api from "../api";
import api from "@/api";
import { m } from "@localizations/messages.js";
import { DeviceStatus } from "./welcome-local";
@ -27,7 +27,7 @@ const loader: LoaderFunction = async () => {
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const localAuthMode = formData.get("localAuthMode");
if (!localAuthMode) return { error: "Please select an authentication mode" };
if (!localAuthMode) return { error: m.auth_authentication_mode() };
if (localAuthMode === "password") {
return redirect("/welcome/password");
@ -41,11 +41,11 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
return redirect("/");
} catch (error) {
console.error("Error setting authentication mode:", error);
return { error: "An error occurred while setting the authentication mode" };
return { error: m.auth_authentication_mode_error() };
}
}
return { error: "Invalid authentication mode" };
return { error: m.auth_authentication_mode_invalid() };
};
export default function WelcomeLocalModeRoute() {
@ -75,10 +75,10 @@ export default function WelcomeLocalModeRoute() {
style={{ animationDelay: "200ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">
Local Authentication Method
{m.auth_mode_local()}
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Select how you{"'"}d like to secure your JetKVM device locally.
{m.auth_mode_local_description()}
</p>
</div>
@ -101,12 +101,12 @@ export default function WelcomeLocalModeRoute() {
>
<div className="space-y-0 text-center">
<h3 className="text-base font-bold text-black dark:text-white">
{mode === "password" ? "Password protected" : "No Password"}
{mode === "password" ? m.auth_mode_local_password() : m.auth_mode_local_no_password()}
</h3>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{mode === "password"
? "Secure your device with a password for added protection."
: "Quick access without password authentication."}
? m.auth_mode_local_password_description()
: m.auth_mode_local_no_password_description()}
</p>
</div>
<input
@ -142,7 +142,7 @@ export default function WelcomeLocalModeRoute() {
theme="primary"
fullWidth
type="submit"
text="Continue"
text={m.continue()}
textAlign="center"
disabled={!selectedMode}
/>
@ -153,7 +153,7 @@ export default function WelcomeLocalModeRoute() {
className="animate-fadeIn mx-auto max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "600ms" }}
>
You can always change your authentication method later in the settings.
{m.auth_mode_local_change_later()}
</p>
</div>
</div>

View File

@ -1,18 +1,18 @@
import { useState, useRef, useEffect } from "react";
import { Form, redirect, useActionData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router";
import { useState, useRef, useEffect } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@assets/logo-blue.png";
import LogoWhiteIcon from "@assets/logo-white.svg";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
import api from "@/api";
import { m } from "@localizations/messages.js";
import { DeviceStatus } from "./welcome-local";
@ -31,7 +31,7 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const confirmPassword = formData.get("confirmPassword");
if (password !== confirmPassword) {
return { error: "Passwords do not match" };
return { error: m.auth_mode_local_password_do_not_match() };
}
try {
@ -43,11 +43,11 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
if (response.ok) {
return redirect("/");
} else {
return { error: "Failed to set password" };
return { error: m.auth_mode_local_password_failed_set({ error: response.statusText }) };
}
} catch (error) {
console.error("Error setting password:", error);
return { error: "An error occurred while setting the password" };
return { error: m.auth_mode_local_password_failed_set({ error: String(error) })};
}
};
@ -86,10 +86,10 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "200ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">
Set a Password
{m.auth_mode_local_password_set()}
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Create a strong password to secure your JetKVM device locally.
{m.auth_mode_local_password_set_description()}
</p>
</div>
@ -101,10 +101,10 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "400ms" }}
>
<InputFieldWithLabel
label="Password"
label={m.auth_mode_local_password()}
type={showPassword ? "text" : "password"}
name="password"
placeholder="Enter a password"
placeholder={m.auth_mode_local_password_set_label()}
autoComplete="new-password"
ref={passwordInputRef}
TrailingElm={
@ -131,11 +131,11 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "400ms" }}
>
<InputFieldWithLabel
label="Confirm Password"
label={m.auth_mode_local_password_confirm_label()}
autoComplete="new-password"
type={showPassword ? "text" : "password"}
name="confirmPassword"
placeholder="Confirm your password"
placeholder={m.auth_mode_local_password_confirm_description()}
error={actionData?.error}
/>
</div>
@ -152,7 +152,7 @@ export default function WelcomeLocalPasswordRoute() {
theme="primary"
fullWidth
type="submit"
text="Set Password"
text={m.auth_mode_local_password_set_button()}
textAlign="center"
/>
</div>
@ -163,9 +163,7 @@ export default function WelcomeLocalPasswordRoute() {
className="animate-fadeIn max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "800ms" }}
>
This password will be used to secure your device data and protect against
unauthorized access.{" "}
<span className="font-bold">All data remains on your local device.</span>
{m.auth_mode_local_password_note()}&nbsp;<span className="font-bold">{m.auth_mode_local_password_note_local()}</span>
</p>
</div>
</div>

View File

@ -1,18 +1,18 @@
import { useEffect, useState } from "react";
import { cx } from "cva";
import { redirect } from "react-router";
import type { LoaderFunction } from "react-router";
import { cx } from "cva";
import GridBackground from "@components/GridBackground";
import LogoBlueIcon from "@assets/logo-blue.png";
import LogoWhiteIcon from "@assets/logo-white.svg";
import DeviceImage from "@assets/jetkvm-device-still.png";
import LogoMark from "@assets/logo-mark.png";
import Container from "@components/Container";
import GridBackground from "@components/GridBackground";
import { LinkButton } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import DeviceImage from "@/assets/jetkvm-device-still.png";
import LogoMark from "@/assets/logo-mark.png";
import api from "@/api";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
import { m } from "@localizations/messages.js";
export interface DeviceStatus {
isSetup: boolean;
@ -49,22 +49,22 @@ export default function WelcomeRoute() {
<div className="animate-fadeIn animation-delay-1000 flex items-center justify-center opacity-0">
<img
src={LogoWhiteIcon}
alt="JetKVM Logo"
alt={m.jetkvm_logo()}
className="hidden h-[32px] dark:block"
/>
<img
src={LogoBlueIcon}
alt="JetKVM Logo"
alt={m.jetkvm_logo()}
className="h-[32px] dark:hidden"
/>
</div>
<div className="animate-fadeIn animation-delay-1500 space-y-1 opacity-0">
<h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome to JetKVM
{m.welcome_to_jetkvm()}
</h1>
<p className="text-lg font-medium text-slate-600 dark:text-slate-400">
Control any computer remotely
{m.welcome_to_jetkvm_description()}
</p>
</div>
</div>
@ -72,7 +72,7 @@ export default function WelcomeRoute() {
<div className="-mt-2! -ml-6 flex items-center justify-center">
<img
src={DeviceImage}
alt="JetKVM Device"
alt={m.jetkvm_device()}
className="animation-delay-300 animate-fadeInScaleFloat max-w-md scale-[0.98] opacity-0 transition-all duration-1000 ease-out"
/>
</div>
@ -82,14 +82,13 @@ export default function WelcomeRoute() {
style={{ animationDelay: "2000ms" }}
className="animate-fadeIn mx-auto max-w-lg text-lg text-slate-700 opacity-0 dark:text-slate-300"
>
JetKVM combines powerful hardware with intuitive software to provide a
seamless remote control experience.
{m.jetkvm_description()}
</p>
<div className="animate-fadeIn animation-delay-2300 opacity-0">
<LinkButton
size="LG"
theme="light"
text="Set up your JetKVM"
text={m.jetkvm_setup()}
LeadingIcon={({ className }) => (
<img src={LogoMark} className={cx(className, "mr-1.5 h-5!")} />
)}

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