This commit is contained in:
Marc Brooks 2025-10-14 07:31:02 +00:00 committed by GitHub
commit 13407d2bd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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,8 +32,11 @@
// 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

@ -97,38 +97,42 @@ tail -f /var/log/jetkvm.log
```
/kvm/
├── main.go # App entry point
├── config.go # Settings & configuration
├── display.go # Device UI control
├── web.go # API endpoints
├── cmd/ # Command line main
├── internal/ # Internal Go packages
│ ├── confparser/ # Configuration file implementation
│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.)
│ ├── logging/ # Logging implementation
│ ├── mdns/ # mDNS implementation
│ ├── native/ # CGO / Native code glue layer (on-device hardware)
│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.)
│ │ └── eez/ # EEZ Studio Project files (for Touchscreen)
│ ├── network/ # Network implementation
│ ├── timesync/ # Time sync/NTP implementation
│ ├── tzdata/ # Timezone data and generation
│ ├── udhcpc/ # DHCP implementation
│ ├── usbgadget/ # USB gadget
│ ├── utils/ # SSH handling
│ └── websecure/ # TLS certificate management
├── resource/ # netboot iso and other resources
├── scripts/ # Bash shell scripts for building and deploying
└── static/ # (react client build output)
└── ui/ # React frontend
├── 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
├── providers/ # Feature flags
└── routes/ # Pages (login, settings, etc.)
├── main.go # App entry point
├── config.go # Settings & configuration
├── display.go # Device UI control
├── web.go # API endpoints
├── cmd/ # Command line main
├── internal/ # Internal Go packages
│ ├── confparser/ # Configuration file implementation
│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.)
│ ├── logging/ # Logging implementation
│ ├── mdns/ # mDNS implementation
│ ├── native/ # CGO / Native code glue layer (on-device hardware)
│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.)
│ │ └── eez/ # EEZ Studio Project files (for Touchscreen)
│ ├── network/ # Network implementation
│ ├── timesync/ # Time sync/NTP implementation
│ ├── tzdata/ # Timezone data and generation
│ ├── udhcpc/ # DHCP implementation
│ ├── usbgadget/ # USB gadget
│ ├── utils/ # SSH handling
│ └── websecure/ # TLS certificate management
├── resource/ # netboot iso and other resources
├── 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.)
```
**Key files for beginners:**

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: {
@ -66,7 +67,7 @@ module.exports = defineConfig([{
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
@ -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 && (
@ -220,10 +219,10 @@ export function MacroForm({
onDelete={
macro.steps && macro.steps.length > 1
? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
}
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
}
: undefined
}
onMoveUp={() => handleStepMove(stepIndex, "up")}
@ -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" },
@ -132,12 +134,12 @@ export function MacroStepCard({
LeadingIcon={LuArrowDown}
/>
</div>
{onDelete && (
{onDelete && (
<Button
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 { 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",
},
];
@ -94,10 +94,10 @@ export function UsbInfoSetting() {
const syncUsbConfigProduct = useCallback(() => {
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
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
@ -162,10 +157,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div>
{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>{" "}
<div className="select-none text-white dark:text-slate-300">
<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

@ -1,5 +1,5 @@
export const DEFAULT_DELAY = 50;
export const MAX_STEPS_PER_MACRO = 10;
export const MAX_KEYS_PER_STEP = 10;
export const MAX_TOTAL_MACROS = 25;
export const MAX_TOTAL_MACROS = 25;
export const COPY_SUFFIX = "(copy)";

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

@ -73,10 +73,10 @@ export interface UIState {
export const useUiStore = create<UIState>(set => ({
terminalType: "none",
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
sidebarView: null,
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
disableVideoFocusTrap: false,
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
@ -195,7 +195,7 @@ export const useRTCStore = create<RTCState>(set => ({
videoStreamStatsHistory: new Map(),
isTurnServerInUse: false,
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(),
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
@ -461,11 +461,11 @@ export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01;
export interface KeysDownState {
modifier: number;
keys: number[];
modifier: number;
keys: number[];
}
export type USBStates =
export type USBStates =
| "configured"
| "attached"
| "not attached"
@ -493,7 +493,7 @@ export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState,
keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
isVirtualKeyboardEnabled: false,
@ -521,34 +521,34 @@ export type UpdateModalViews =
| "error";
export interface OtaState {
updating: boolean;
error: string | null;
updating: boolean;
error: string | null;
metadataFetchedAt: string | null;
metadataFetchedAt: string | null;
// App update
appUpdatePending: boolean;
// App update
appUpdatePending: boolean;
appDownloadProgress: number;
appDownloadFinishedAt: string | null;
appDownloadProgress: number;
appDownloadFinishedAt: string | null;
appVerificationProgress: number;
appVerifiedAt: string | null;
appVerificationProgress: number;
appVerifiedAt: string | null;
appUpdateProgress: number;
appUpdatedAt: string | null;
appUpdateProgress: number;
appUpdatedAt: string | null;
// System update
systemUpdatePending: boolean;
// System update
systemUpdatePending: boolean;
systemDownloadProgress: number;
systemDownloadFinishedAt: string | null;
systemDownloadProgress: number;
systemDownloadFinishedAt: string | null;
systemVerificationProgress: number;
systemVerifiedAt: string | null;
systemVerificationProgress: number;
systemVerifiedAt: string | null;
systemUpdateProgress: number;
systemUpdatedAt: string | null;
systemUpdateProgress: number;
systemUpdatedAt: string | null;
};
export interface UpdateState {
@ -603,7 +603,7 @@ export type UsbConfigModalViews =
| "updateUsbConfigSuccess";
export interface UsbConfigModalState {
modalView: UsbConfigModalViews ;
modalView: UsbConfigModalViews;
errorMessage: string | null;
setModalView: (view: UsbConfigModalViews) => void;
setErrorMessage: (message: string | null) => void;
@ -620,7 +620,7 @@ export interface UsbConfigState {
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig",
errorMessage: null,
setModalView: (view: UsbConfigModalViews) => set({ modalView: view }),
setModalView: (view: UsbConfigModalViews) => set({ modalView: view }),
setErrorMessage: (message: string | null) => set({ errorMessage: message }),
}));
@ -633,13 +633,13 @@ export type LocalAuthModalViews =
| "updateSuccess";
export interface LocalAuthModalState {
modalView:LocalAuthModalViews;
setModalView: (view:LocalAuthModalViews) => void;
modalView: LocalAuthModalViews;
setModalView: (view: LocalAuthModalViews) => void;
}
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword",
setModalView: (view: LocalAuthModalViews) => set({ modalView: view }),
setModalView: (view: LocalAuthModalViews) => set({ modalView: view }),
}));
export interface DeviceState {
@ -779,12 +779,12 @@ export interface MacrosState {
loadMacros: () => Promise<void>;
saveMacros: (macros: KeySequence[]) => Promise<void>;
sendFn:
| ((
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void)
| null;
| ((
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void)
| null;
setSendFn: (
sendFn: (
method: string,

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { useRTCStore } from "@/hooks/stores";
import { useRTCStore } from "@hooks/stores";
import {
CancelKeyboardMacroReportMessage,
@ -71,7 +71,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
}: sendMessageParams = {},
) => {
if (hidRpcDisabled) return;
if (rpcHidChannel?.readyState !== "open") return;
if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) return;
let data: Uint8Array | undefined;
@ -163,7 +163,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
(message: HandshakeMessage) => {
if (hidRpcDisabled) return;
if (!message.version) {
if (!message.version) {
console.error("Received handshake message without version", message);
return;
}
@ -238,7 +238,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
setRpcHidProtocolVersion,
sendHandshake,
handleHandshake,
hidRpcDisabled,
hidRpcDisabled,
]);
return {

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;
@ -79,7 +79,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
rpcDataChannel.removeEventListener("message", messageHandler);
};
},
[rpcDataChannel, onRequest]);
[rpcDataChannel, onRequest]);
return { send };
}

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

@ -28,7 +28,7 @@ export default function useKeyboardLayout() {
const selectedKeyboard = useMemo(() => {
// fallback to original behaviour of en-US if no isoCode given or matching layout not found
return keyboards.find(keyboard => keyboard.isoCode === isoCode)
?? keyboards.find(keyboard => keyboard.isoCode === "en-US")!;
?? keyboards.find(keyboard => keyboard.isoCode === "en-US")!;
}, [isoCode]);
return { keyboardOptions, isoCode, selectedKeyboard };

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

@ -29,4 +29,4 @@ import { nb_NO } from "@/keyboardLayouts/nb_NO"
import { sv_SE } from "@/keyboardLayouts/sv_SE"
import { da_DK } from "@/keyboardLayouts/da_DK"
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE, da_DK ];
export const keyboards: KeyboardLayout[] = [cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE, da_DK];

View File

@ -197,7 +197,7 @@ const chars = {
z: { key: "KeyZ" },
"ż": { key: "KeyZ", accentKey: keyOverdot },
";": { key: "Backquote" },
"°": { key: "Backquote", shift: true, deadKey: true },
"°": { key: "Backquote", shift: true, deadKey: true },
"+": { key: "Digit1" },
1: { key: "Digit1", shift: true },
"ě": { key: "Digit2" },
@ -251,7 +251,7 @@ export const cs_CZ: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -6,10 +6,10 @@ export const name = "Dansk";
const isoCode = "da-DK";
const keyTrema = { key: "BracketRight" }
const keyAcute = { key: "Equal", altRight: true }
const keyHat = { key: "BracketRight", shift: true }
const keyGrave = { key: "Equal", shift: true }
const keyTilde = { key: "BracketRight", altRight: true }
const keyAcute = { key: "Equal", altRight: true }
const keyHat = { key: "BracketRight", shift: true }
const keyGrave = { key: "Equal", shift: true }
const keyTilde = { key: "BracketRight", altRight: true }
export const chars = {
A: { key: "KeyA", shift: true },
@ -61,8 +61,8 @@ export const chars = {
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema },
"á": { key: "KeyA", accentKey: keyAcute },
@ -115,7 +115,7 @@ export const chars = {
x: { key: "KeyX" },
y: { key: "KeyY" }, // <-- corrected
z: { key: "KeyZ" }, // <-- corrected
"½": { key: "Backquote" },
"½": { key: "Backquote" },
"§": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
@ -163,11 +163,11 @@ export const chars = {
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
"~": { key: "BracketRight", deadKey: true, altRight: true },
"^": { key: "BracketRight", deadKey: true, shift: true },
"¨": { key: "BracketRight", deadKey: true, },
"|": { key: "Equal", deadKey: true, altRight: true},
"`": { key: "Equal", deadKey: true, shift: true, },
"~": { key: "BracketRight", deadKey: true, altRight: true },
"^": { key: "BracketRight", deadKey: true, shift: true },
"¨": { key: "BracketRight", deadKey: true, },
"|": { key: "Equal", deadKey: true, altRight: true },
"`": { key: "Equal", deadKey: true, shift: true, },
"´": { key: "Equal", deadKey: true, },
" ": { key: "Space" },
"\n": { key: "Enter" },
@ -181,7 +181,7 @@ export const da_DK: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -181,7 +181,7 @@ export const de_CH: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: keyDisplayMap,
keyDisplayMap: keyDisplayMap,
// TODO need to localize these maps and layouts
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard

View File

@ -114,7 +114,7 @@ export const en_UK: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -113,7 +113,7 @@ export const chars = {
"~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Escape: { key: "Escape" },
@ -264,13 +264,13 @@ export const virtualKeyboard = {
},
control: {
default: [
"PrintScreen ScrollLock Pause",
"PrintScreen ScrollLock Pause",
"Insert Home PageUp",
"Delete End PageDown"
],
shift: [
"(PrintScreen) ScrollLock (Pause)",
"Insert Home PageUp",
"Insert Home PageUp",
"Delete End PageDown"
],
},
@ -303,7 +303,7 @@ export const en_US: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard
};

View File

@ -116,8 +116,8 @@ const chars = {
y: { key: "KeyY" },
z: { key: "KeyZ" },
"º": { key: "Backquote" },
"ª": { key: "Backquote", shift: true },
"\\": { key: "Backquote", altRight: true },
"ª": { key: "Backquote", shift: true },
"\\": { key: "Backquote", altRight: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
"|": { key: "Digit1", altRight: true },
@ -175,7 +175,7 @@ export const es_ES: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -174,7 +174,7 @@ export const fr_BE: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -29,7 +29,7 @@ export const fr_CH: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: keyDisplayMap,
keyDisplayMap: keyDisplayMap,
// TODO need to localize these maps and layouts
modifierDisplayMap: de_CH.modifierDisplayMap,
virtualKeyboard: de_CH.virtualKeyboard

View File

@ -146,7 +146,7 @@ export const fr_FR: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -60,7 +60,7 @@ const chars = {
y: { key: "KeyY" },
z: { key: "KeyZ" },
"\\": { key: "Backquote" },
"|": { key: "Backquote", shift: true },
"|": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
@ -120,7 +120,7 @@ export const it_IT: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -115,7 +115,7 @@ const chars = {
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
"|": { key: "Backquote" },
"|": { key: "Backquote" },
"§": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },

View File

@ -112,7 +112,7 @@ const chars = {
y: { key: "KeyY" },
z: { key: "KeyZ" },
"§": { key: "Backquote" },
"½": { key: "Backquote", shift: true },
"½": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
@ -171,7 +171,7 @@ export const sv_SE: KeyboardLayout = {
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -121,7 +121,7 @@ export const keys = {
Hanja: 0x91,
Katakana: 0x92,
Hiragana: 0x93,
ZenkakuHankaku:0x94,
ZenkakuHankaku: 0x94,
LockingCapsLock: 0x82,
LockingNumLock: 0x83,
LockingScrollLock: 0x84,
@ -279,7 +279,7 @@ export const hidKeyToModifierMask = {
export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"];
export function decodeModifiers(modifier: number) {
return {
return {
isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0,
isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0,
isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0,

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";
@ -28,12 +29,12 @@ import DeviceIdRename from "@routes/devices.$id.rename";
import DevicesRoute from "@routes/devices";
import SettingsIndexRoute from "@routes/devices.$id.settings._index";
import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index";
import Notifications from "@/notifications";
import Notifications from "@/notifications";
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 { 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}
<br />
System: {currentVersions.systemVersion}
</>
) : (
<>
App: Loading...
<br />
System: Loading...
</>
)
<>
{m.general_app_version({ version: currentVersions ? currentVersions.appVersion : m.loading() })}
<br />
{m.general_system_version({ version: currentVersions ? currentVersions.systemVersion : m.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();
@ -10,7 +11,7 @@ export default function SettingsGeneralRebootRoute() {
const onConfirmUpdate = useCallback(() => {
// This is where we send the RPC to the golang binary
send("reboot", {force: true});
send("reboot", { force: true });
}, [send]);
{
@ -30,10 +31,10 @@ export function Dialog({
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
<ConfirmationBox
onYes={onConfirmUpdate}
onNo={onClose}
/>
<ConfirmationBox
onYes={onConfirmUpdate}
onNo={onClose}
/>
</div>
</div>
);
@ -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>

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