This commit is contained in:
Marc Brooks 2025-10-14 01:12:42 +00:00 committed by GitHub
commit 5a2d948192
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
116 changed files with 3685 additions and 1673 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,595 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"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",
"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",
"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...",
"getting_remote_session_description": "Getting remote session description attempt {attempt}",
"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",
"kvm_terminal": "KVM Terminal",
"last_online": "Last online {time}",
"learn_more": "Learn more",
"load": "Load",
"loading": "Loading…",
"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",
"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",
"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",
"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",
"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",
"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"
}

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,7 +1,7 @@
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";
export default function SettingsGeneralRebootRoute() {
@ -10,7 +10,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 +30,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>
);

View File

@ -41,8 +41,6 @@ export default function SettingsGeneralUpdateRoute() {
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({
onClose,
onConfirmUpdate,
@ -71,11 +69,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 +126,6 @@ function LoadingState({
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setProgressWidth("0%");
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;

View File

@ -1,20 +1,19 @@
import { useEffect } from "react";
import { BacklightSettings, useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { FeatureFlag } from "@components/FeatureFlag";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag";
import { UsbInfoSetting } from "@components/UsbInfoSetting";
import notifications from "@/notifications";
export default function SettingsHardwareRoute() {
const { send } = useJsonRpc();
const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore();
const { displayRotation, setDisplayRotation } = useSettingsStore();
const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation);
@ -22,7 +21,7 @@ export default function SettingsHardwareRoute() {
};
const handleDisplayRotationSave = () => {
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => {
send("setDisplayRotation", { params: { rotation: displayRotation } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
@ -33,7 +32,7 @@ export default function SettingsHardwareRoute() {
});
};
const { setBacklightSettings } = useSettingsStore();
const { backlightSettings, setBacklightSettings } = useSettingsStore();
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
// If the user has set the display to dim after it turns off, set the dim_after
@ -47,7 +46,7 @@ export default function SettingsHardwareRoute() {
};
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => {
send("setBacklightSettings", { params: backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
@ -58,6 +57,21 @@ export default function SettingsHardwareRoute() {
});
};
const handleBacklightMaxBrightnessChange = (max_brightness: number) => {
const settings = { ...backlightSettings, max_brightness };
handleBacklightSettingsChange(settings);
};
const handleBacklightDimAfterChange = (dim_after: number) => {
const settings = { ...backlightSettings, dim_after };
handleBacklightSettingsChange(settings);
};
const handleBacklightOffAfterChange = (off_after: number) => {
const settings = { ...backlightSettings, off_after };
handleBacklightSettingsChange(settings);
};
useEffect(() => {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
@ -90,8 +104,7 @@ export default function SettingsHardwareRoute() {
{ value: "90", label: "Inverted" },
]}
onChange={e => {
settings.displayRotation = e.target.value;
handleDisplayRotationChange(settings.displayRotation);
handleDisplayRotationChange(e.target.value);
}}
/>
</SettingsItem>
@ -102,7 +115,7 @@ export default function SettingsHardwareRoute() {
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
value={backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
@ -110,12 +123,11 @@ export default function SettingsHardwareRoute() {
{ value: "64", label: "High" },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
handleBacklightMaxBrightnessChange(parseInt(e.target.value));
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
{backlightSettings.max_brightness != 0 && (
<>
<SettingsItem
title="Dim Display After"
@ -124,7 +136,7 @@ export default function SettingsHardwareRoute() {
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
value={backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
@ -134,8 +146,7 @@ export default function SettingsHardwareRoute() {
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
handleBacklightDimAfterChange(parseInt(e.target.value));
}}
/>
</SettingsItem>
@ -146,7 +157,7 @@ export default function SettingsHardwareRoute() {
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
value={backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
@ -155,8 +166,7 @@ export default function SettingsHardwareRoute() {
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
handleBacklightOffAfterChange(parseInt(e.target.value));
}}
/>
</SettingsItem>

View File

@ -113,7 +113,6 @@ export default function SettingsMacrosEditRoute() {
onSubmit={handleUpdateMacro}
onCancel={() => navigate("../")}
isSubmitting={isUpdating}
submitText="Save Changes"
/>
<ConfirmDialog

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