add i18n support

This commit is contained in:
oupula 2025-09-28 17:28:57 +08:00
parent 114095ae4f
commit 6d9380fefc
68 changed files with 1440 additions and 1175 deletions

157
ui/package-lock.json generated
View File

@ -23,12 +23,15 @@
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4", "focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.0.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router": "^7.8.2", "react-router": "^7.8.2",
"react-simple-keyboard": "^3.8.119", "react-simple-keyboard": "^3.8.119",
@ -88,6 +91,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@ -2913,6 +2925,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4368,6 +4389,65 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.5.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz",
"integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -5381,6 +5461,26 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -5904,6 +6004,32 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-i18next": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
"integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 25.5.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@ -6680,6 +6806,12 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@ -7075,6 +7207,31 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -31,6 +31,9 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.4", "cva": "^1.0.0-beta.4",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"react-i18next": "^16.0.0",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4", "focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",

View File

@ -4,6 +4,7 @@ import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid"; import { CommandLineIcon } from "@heroicons/react/20/solid";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { import {
@ -17,7 +18,7 @@ import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal"; import PasteModal from "@/components/popovers/PasteModal";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover"; import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover"; import {ExtensionPopover} from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function Actionbar({ export default function Actionbar({
@ -33,7 +34,7 @@ export default function Actionbar({
state => state.remoteVirtualMediaState, state => state.remoteVirtualMediaState,
); );
const { developerMode } = useSettingsStore(); const { developerMode } = useSettingsStore();
const { t } = useTranslation();
// This is the only way to get a reliable state change for the popover // This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover // at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false); const isOpen = useRef<boolean>(false);
@ -64,7 +65,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Web Terminal" text={t('Web_Terminal')}
LeadingIcon={({ className }) => <CommandLineIcon className={className} />} LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")} onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/> />
@ -74,7 +75,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Paste text" text={t('Paste_text')}
LeadingIcon={MdOutlineContentPasteGo} LeadingIcon={MdOutlineContentPasteGo}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableVideoFocusTrap(true);
@ -105,7 +106,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Virtual Media" text={t('Virtual_Media')}
LeadingIcon={({ className }) => { LeadingIcon={({ className }) => {
return ( return (
<> <>
@ -148,7 +149,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Wake on LAN" text={t("Wake_on_LAN")}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableVideoFocusTrap(true);
}} }}
@ -198,7 +199,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Virtual Keyboard" text={t('Virtual_Keyboard')}
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/> />
@ -211,7 +212,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Extension" text={t('Extension')}
LeadingIcon={LuCable} LeadingIcon={LuCable}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableVideoFocusTrap(true);
@ -237,7 +238,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Virtual Keyboard" text={t('Virtual_Keyboard')}
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/> />
@ -246,7 +247,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Connection Stats" text={t('Connection_Stats')}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<LuSignal <LuSignal
className={cx(className, "mb-0.5 text-green-500")} className={cx(className, "mb-0.5 text-green-500")}
@ -262,7 +263,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Settings" text={t('Settings')}
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableVideoFocusTrap(true);
@ -276,7 +277,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Fullscreen" text={t('Fullscreen')}
LeadingIcon={LuMaximize} LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()} onClick={() => requestFullscreen()}
/> />

View File

@ -53,7 +53,6 @@ export function Combobox({
}: ComboboxProps) { }: ComboboxProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const classes = comboboxVariants({ size }); const classes = comboboxVariants({ size });
return ( return (
<HeadlessCombobox onChange={onChange} {...otherProps}> <HeadlessCombobox onChange={onChange} {...otherProps}>
{() => ( {() => (

View File

@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { import {
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
@ -56,20 +58,20 @@ const variantConfig = {
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger"; buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
} }
>; >;
// @ts-ignore
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
onClose, onClose,
title, title,
description, description,
variant = "info", variant = "info",
confirmText = "Confirm", confirmText = useTranslation('Confirm').toString(),
cancelText = "Cancel", cancelText = useTranslation('Cancel').toString(),
onConfirm, onConfirm,
isConfirming = false, isConfirming = false,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant]; const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
const { t } = useTranslation();
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out"> <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
@ -96,7 +98,7 @@ export function ConfirmDialog({
<div className="flex justify-end gap-x-2"> <div className="flex justify-end gap-x-2">
{cancelText && ( {cancelText && (
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} /> <Button size="SM" theme="blank" text={t('Cancel')} onClick={onClose} />
)} )}
<Button <Button
size="SM" size="SM"

View File

@ -4,6 +4,7 @@ import { Button } from "@/components/Button";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network"; import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores"; import { NetworkState } from "@/hooks/stores";
import { useTranslation } from "react-i18next";
export default function DhcpLeaseCard({ export default function DhcpLeaseCard({
networkState, networkState,
@ -12,12 +13,13 @@ export default function DhcpLeaseCard({
networkState: NetworkState; networkState: NetworkState;
setShowRenewLeaseConfirm: (show: boolean) => void; setShowRenewLeaseConfirm: (show: boolean) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<GridCard> <GridCard>
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white"> <div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information {t('DHCP_Lease_Information')}
</h3> </h3>
<div className="flex gap-x-6 gap-y-2"> <div className="flex gap-x-6 gap-y-2">
@ -25,7 +27,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.ip && ( {networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
IP Address {t('IP_Address')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip} {networkState?.dhcp_lease?.ip}
@ -36,7 +38,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.netmask && ( {networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask {t('Subnet_Mask')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask} {networkState?.dhcp_lease?.netmask}
@ -47,7 +49,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.dns && ( {networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers {t('DNS_Servers')}
</span> </span>
<span className="text-right text-sm font-medium"> <span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)} {networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
@ -58,7 +60,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.broadcast && ( {networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast {t('Broadcast')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast} {networkState?.dhcp_lease?.broadcast}
@ -69,7 +71,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.domain && ( {networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Domain {t('Domain')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain} {networkState?.dhcp_lease?.domain}
@ -81,7 +83,7 @@ export default function DhcpLeaseCard({
networkState?.dhcp_lease?.ntp_servers.length > 0 && ( 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="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"> <div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers {t('NTP_Servers')}
</div> </div>
<div className="shrink text-right text-sm font-medium"> <div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => ( {networkState?.dhcp_lease?.ntp_servers.map(server => (
@ -94,7 +96,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.hostname && ( {networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Hostname {t('Hostname')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname} {networkState?.dhcp_lease?.hostname}
@ -108,7 +110,7 @@ export default function DhcpLeaseCard({
networkState?.dhcp_lease?.routers.length > 0 && ( networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2"> <div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400"> <span className="text-sm text-slate-600 dark:text-slate-400">
Gateway {t('Gateway')}
</span> </span>
<span className="text-right text-sm font-medium"> <span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => ( {networkState?.dhcp_lease?.routers.map(router => (
@ -121,7 +123,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.server_id && ( {networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server {t('DHCP_Server')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id} {networkState?.dhcp_lease?.server_id}
@ -132,7 +134,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.lease_expiry && ( {networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires {t('Lease_Expires')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
<LifeTimeLabel <LifeTimeLabel
@ -163,7 +165,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_next_server && ( {networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server {t('Boot_Next_Server')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server} {networkState?.dhcp_lease?.bootp_next_server}
@ -174,7 +176,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_server_name && ( {networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name {t('Boot_Next_Server')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name} {networkState?.dhcp_lease?.bootp_server_name}
@ -185,7 +187,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_file && ( {networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
Boot File {t('Boot_File')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file} {networkState?.dhcp_lease?.bootp_file}
@ -200,7 +202,7 @@ export default function DhcpLeaseCard({
size="SM" size="SM"
theme="light" theme="light"
className="text-red-500" className="text-red-500"
text="Renew DHCP Lease" text={t('Renew_DHCP_Lease')}
LeadingIcon={LuRefreshCcw} LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)} onClick={() => setShowRenewLeaseConfirm(true)}
/> />

View File

@ -27,7 +27,7 @@ interface NavbarProps {
kvmName?: string; kvmName?: string;
} }
export default function DashboardNavbar({ export default function DashboardNavbar({
primaryLinks = [], primaryLinks = [],
isLoggedIn, isLoggedIn,
showConnectionStatus, showConnectionStatus,
@ -47,9 +47,7 @@ export default function DashboardNavbar({
// The root route will redirect to appropriate login page, be it the local one or the cloud one // The root route will redirect to appropriate login page, be it the local one or the cloud one
navigate("/"); navigate("/");
}, [navigate, setUser]); }, [navigate, setUser]);
const { usbState } = useHidStore(); const { usbState } = useHidStore();
// for testing // for testing
//userEmail = "user@example.org"; //userEmail = "user@example.org";
//picture = "https://placehold.co/32x32" //picture = "https://placehold.co/32x32"

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
@ -51,6 +52,7 @@ export default function InfoBar() {
return [...modifierNames, ...keyNames].join(", "); return [...modifierNames, ...keyNames].join(", ");
}, [keysDownState, showPressedKeys]); }, [keysDownState, showPressedKeys]);
const { t } = useTranslation();
return ( return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300"> <div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
@ -59,21 +61,21 @@ export default function InfoBar() {
<div className="flex flex-wrap items-center pl-2 gap-x-4"> <div className="flex flex-wrap items-center pl-2 gap-x-4">
{debugMode ? ( {debugMode ? (
<div className="flex"> <div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "} <span className="text-xs font-semibold">{t('Resolution')}:</span>{" "}
<span className="text-xs">{videoSize}</span> <span className="text-xs">{videoSize}</span>
</div> </div>
) : null} ) : null}
{debugMode ? ( {debugMode ? (
<div className="flex"> <div className="flex">
<span className="text-xs font-semibold">Video Size: </span> <span className="text-xs font-semibold">{t('Video_Size')}: </span>
<span className="text-xs">{videoClientSize}</span> <span className="text-xs">{videoClientSize}</span>
</div> </div>
) : null} ) : null}
{(debugMode && mouseMode == "absolute") ? ( {(debugMode && mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[140px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span> <span className="text-xs font-semibold">{t('Pointer')}:</span>
<span className="text-xs"> <span className="text-xs">
{mouseX},{mouseY} {mouseX},{mouseY}
</span> </span>
@ -81,8 +83,8 @@ export default function InfoBar() {
) : null} ) : null}
{(debugMode && mouseMode == "relative") ? ( {(debugMode && mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span> <span className="text-xs font-semibold">{t('Last_Move')}:</span>
<span className="text-xs"> <span className="text-xs">
{mouseMove ? {mouseMove ?
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` : `${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
@ -93,31 +95,31 @@ export default function InfoBar() {
{debugMode && ( {debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <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">{t('USB_State')}:</span>
<span className="text-xs">{usbState}</span> <span className="text-xs">{t(usbState.replace(' ','_').toString())}</span>
</div> </div>
)} )}
{debugMode && ( {debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <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">{t('HDMI_State')}:</span>
<span className="text-xs">{hdmiState}</span> <span className="text-xs">{t(hdmiState.toString())}</span>
</div> </div>
)} )}
{debugMode && ( {debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[168px] items-center gap-x-1">
<span className="text-xs font-semibold">HidRPC State:</span> <span className="text-xs font-semibold">{t('HidRPC_State')}:</span>
<span className="text-xs">{rpcHidStatus}</span> <span className="text-xs">{t(rpcHidStatus.toString().replace(' ','_'))}</span>
</div> </div>
)} )}
{isPasteInProgress && ( {isPasteInProgress && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">Paste Mode:</span> <span className="text-xs font-semibold">{t('Paste_Mode')}:</span>
<span className="text-xs">Enabled</span> <span className="text-xs">{t('Enabled')}</span>
</div> </div>
)} )}
{showPressedKeys && ( {showPressedKeys && (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span> <span className="text-xs font-semibold">{t('Keys')}:</span>
<h2 className="text-xs"> <h2 className="text-xs">
{displayKeys} {displayKeys}
</h2> </h2>
@ -128,7 +130,7 @@ export default function InfoBar() {
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20"> <div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
{isTurnServerInUse && ( {isTurnServerInUse && (
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white"> <div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
Relayed by Cloudflare {t('Relayed_by_Cloudflare')}
</div> </div>
)} )}

View File

@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { NetworkState } from "../hooks/stores"; import { NetworkState } from "../hooks/stores";
import { LifeTimeLabel } from "../routes/devices.$id.settings.network"; import { LifeTimeLabel } from "../routes/devices.$id.settings.network";
@ -8,19 +10,20 @@ export default function Ipv6NetworkCard({
}: { }: {
networkState: NetworkState; networkState: NetworkState;
}) { }) {
const { t } = useTranslation();
return ( return (
<GridCard> <GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white"> <div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information {t('IPv6_Information')}
</h3> </h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2"> <div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.ipv6_link_local && ( {networkState?.ipv6_link_local && (
<div className="flex flex-col justify-between"> <div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400"> <span className="text-sm text-slate-600 dark:text-slate-400">
Link-local {t('Link-local')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{networkState?.ipv6_link_local} {networkState?.ipv6_link_local}
@ -32,7 +35,7 @@ export default function Ipv6NetworkCard({
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && ( {networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-semibold">IPv6 Addresses</h4> <h4 className="text-sm font-semibold">{t('IPv6_Addresses')}</h4>
{networkState.ipv6_addresses.map( {networkState.ipv6_addresses.map(
addr => ( addr => (
<div <div
@ -42,7 +45,7 @@ export default function Ipv6NetworkCard({
<div className="grid grid-cols-2 gap-x-8 gap-y-4"> <div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between"> <div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400"> <span className="text-sm text-slate-600 dark:text-slate-400">
Address {t('Address')}
</span> </span>
<span className="text-sm font-medium">{addr.address}</span> <span className="text-sm font-medium">{addr.address}</span>
</div> </div>
@ -50,7 +53,7 @@ export default function Ipv6NetworkCard({
{addr.valid_lifetime && ( {addr.valid_lifetime && (
<div className="flex flex-col justify-between"> <div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400"> <span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime {t('Valid_Lifetime')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{addr.valid_lifetime === "" ? ( {addr.valid_lifetime === "" ? (
@ -66,7 +69,7 @@ export default function Ipv6NetworkCard({
{addr.preferred_lifetime && ( {addr.preferred_lifetime && (
<div className="flex flex-col justify-between"> <div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400"> <span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime {t('Valid_Lifetime')}
</span> </span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? ( {addr.preferred_lifetime === "" ? (

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
@ -31,6 +32,7 @@ export function JigglerSetting({
); );
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [timezones, setTimezones] = useState<string[]>([]); const [timezones, setTimezones] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
@ -51,7 +53,7 @@ export function JigglerSetting({
const exampleConfigs = [ const exampleConfigs = [
{ {
name: "Business Hours 9-17", name: t('Business_Hours_9-17'),
config: { config: {
inactivity_limit_seconds: 60, inactivity_limit_seconds: 60,
jitter_percentage: 25, jitter_percentage: 25,
@ -60,7 +62,7 @@ export function JigglerSetting({
}, },
}, },
{ {
name: "Business Hours 8-17", name: t('Business_Hours_9-17'),
config: { config: {
inactivity_limit_seconds: 60, inactivity_limit_seconds: 60,
jitter_percentage: 25, jitter_percentage: 25,
@ -74,7 +76,7 @@ export function JigglerSetting({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100"> <h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Examples {t('Examples')}
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{exampleConfigs.map((example, index) => ( {exampleConfigs.map((example, index) => (
@ -90,7 +92,7 @@ export function JigglerSetting({
to="https://crontab.guru/examples.html" to="https://crontab.guru/examples.html"
size="XS" size="XS"
theme="light" theme="light"
text="More examples" text={t('More_examples')}
LeadingIcon={LuExternalLink} LeadingIcon={LuExternalLink}
/> />
</div> </div>
@ -100,8 +102,8 @@ export function JigglerSetting({
<InputFieldWithLabel <InputFieldWithLabel
required required
size="SM" size="SM"
label="Cron Schedule" label={t('Cron_Schedule')}
description="Cron expression for scheduling" description={t('Cron_expression_for_scheduling')}
placeholder="*/20 * * * * *" placeholder="*/20 * * * * *"
value={jigglerConfigState.schedule_cron_tab} value={jigglerConfigState.schedule_cron_tab}
onChange={e => onChange={e =>
@ -114,8 +116,8 @@ export function JigglerSetting({
<InputFieldWithLabel <InputFieldWithLabel
size="SM" size="SM"
label="Inactivity Limit Seconds" label={t('Inactivity_Limit_Seconds')}
description="Inactivity time before jiggle" description={t('Inactivity_time_before_jiggle')}
value={jigglerConfigState.inactivity_limit_seconds} value={jigglerConfigState.inactivity_limit_seconds}
type="number" type="number"
min="1" min="1"
@ -131,8 +133,8 @@ export function JigglerSetting({
<InputFieldWithLabel <InputFieldWithLabel
required required
size="SM" size="SM"
label="Random delay" label={t('Random_delay')}
description="To avoid recognizable patterns" description={t('To_avoid_recognizable_patterns')}
placeholder="25" placeholder="25"
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>} TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
value={jigglerConfigState.jitter_percentage} value={jigglerConfigState.jitter_percentage}
@ -149,8 +151,8 @@ export function JigglerSetting({
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="Timezone" label={t('Timezone')}
description="Timezone for cron schedule" description={t('Timezone_for_cron_schedule')}
value={jigglerConfigState.timezone || "UTC"} value={jigglerConfigState.timezone || "UTC"}
disabled={timezones.length === 0} disabled={timezones.length === 0}
onChange={e => onChange={e =>
@ -167,7 +169,7 @@ export function JigglerSetting({
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Save Jiggler Config" text={t('Save_Jiggler_Config')}
onClick={() => onSave(jigglerConfigState)} onClick={() => onSave(jigglerConfigState)}
/> />
</div> </div>

View File

@ -2,6 +2,7 @@ import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router"; import { Link } from "react-router";
import { LuEllipsisVertical } from "react-icons/lu"; import { LuEllipsisVertical } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import Card from "@components/Card"; import Card from "@components/Card";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
@ -50,6 +51,7 @@ export default function KvmCard({
online: boolean; online: boolean;
lastSeen: Date | null; lastSeen: Date | null;
}) { }) {
const { t } = useTranslation();
return ( return (
<Card> <Card>
<div className="px-5 py-5 space-y-3"> <div className="px-5 py-5 space-y-3">
@ -69,9 +71,9 @@ export default function KvmCard({
<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="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"> <div className="text-sm text-black dark:text-white">
{lastSeen ? ( {lastSeen ? (
<>Last online {getRelativeTimeString(lastSeen)}</> <>{t('Last_online')} {getRelativeTimeString(lastSeen)}</>
) : ( ) : (
<>Never seen online</> <>{t('Never_seen_online')}</>
)} )}
</div> </div>
</div> </div>
@ -85,7 +87,7 @@ export default function KvmCard({
<LinkButton <LinkButton
size="MD" size="MD"
theme="light" theme="light"
text="Connect to KVM" text={t('Connect_to_KVM')}
LeadingIcon={MdConnectWithoutContact} LeadingIcon={MdConnectWithoutContact}
textAlign="center" textAlign="center"
to={`/devices/${id}`} to={`/devices/${id}`}
@ -94,7 +96,7 @@ export default function KvmCard({
<Button <Button
size="MD" size="MD"
theme="light" theme="light"
text="Troubleshoot Connection" text={t('Troubleshoot_Connection')}
textAlign="center" textAlign="center"
/> />
)} )}
@ -120,7 +122,7 @@ export default function KvmCard({
className="block w-full py-1.5 text-black dark:text-white" className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/rename`} to={`./${id}/rename`}
> >
Rename {t('Rename')}
</Link> </Link>
</div> </div>
</div> </div>
@ -134,7 +136,7 @@ export default function KvmCard({
className="block w-full py-1.5 text-black dark:text-white" className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/deregister`} to={`./${id}/deregister`}
> >
Deregister from cloud {t('Deregister_from_Cloud')}
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
@ -41,6 +42,7 @@ export function MacroForm({
isSubmitting = false, isSubmitting = false,
submitText = "Save Macro", submitText = "Save Macro",
}: MacroFormProps) { }: MacroFormProps) {
const { t } = useTranslation();
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData); const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({}); const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({}); const [errors, setErrors] = useState<ValidationErrors>({});
@ -57,13 +59,13 @@ export function MacroForm({
// Name validation // Name validation
if (!macro.name?.trim()) { if (!macro.name?.trim()) {
newErrors.name = "Name is required"; newErrors.name = t('Name_is_required');
} else if (macro.name.trim().length > 50) { } else if (macro.name.trim().length > 50) {
newErrors.name = "Name must be less than 50 characters"; newErrors.name = t('Name_must_be_less_than_50_characters');
} }
if (!macro.steps?.length) { if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } }; newErrors.steps = { 0: { keys: t('At_least_one_step_is_required') } };
} else { } else {
const hasKeyOrModifier = macro.steps.some( const hasKeyOrModifier = macro.steps.some(
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0, step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
@ -71,7 +73,7 @@ export function MacroForm({
if (!hasKeyOrModifier) { if (!hasKeyOrModifier) {
newErrors.steps = { newErrors.steps = {
0: { keys: "At least one step must have keys or modifiers" }, 0: { keys: t('At_least_one_step_must_have_keys_or_modifiers') },
}; };
} }
} }
@ -82,7 +84,7 @@ export function MacroForm({
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validateForm()) { if (!validateForm()) {
showTemporaryError("Please fix the validation errors"); showTemporaryError(t('Please_fix_the_validation_errors'));
return; return;
} }
@ -92,7 +94,7 @@ export function MacroForm({
if (error instanceof Error) { if (error instanceof Error) {
showTemporaryError(error.message); showTemporaryError(error.message);
} else { } else {
showTemporaryError("An error occurred while saving"); showTemporaryError(t('An_error_occurred_while_saving'));
} }
} }
}; };
@ -114,7 +116,7 @@ export function MacroForm({
? newSteps[stepIndex].keys ? newSteps[stepIndex].keys
: []; : [];
if (keysArray.length >= MAX_KEYS_PER_STEP) { if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); showTemporaryError(t('Maximum_of_keys_per_step_allowed',{max_key:MAX_KEYS_PER_STEP}));
return; return;
} }
newSteps[stepIndex].keys = [...keysArray, option.value]; newSteps[stepIndex].keys = [...keysArray, option.value];
@ -178,8 +180,8 @@ export function MacroForm({
<Fieldset> <Fieldset>
<InputFieldWithLabel <InputFieldWithLabel
type="text" type="text"
label="Macro Name" label={t('Macro_Name')}
placeholder="Macro Name" placeholder={t('Macro_Name')}
value={macro.name} value={macro.name}
error={errors.name} error={errors.name}
onChange={e => { onChange={e => {
@ -197,12 +199,12 @@ export function MacroForm({
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel <FieldLabel
label="Steps" label={t('Steps')}
description={`Keys/modifiers executed in sequence with a delay between each step.`} description={t('Keys_modifiers_executed_in_sequence_with_a_delay_between_each_step')}
/> />
</div> </div>
<span className="text-slate-500 dark:text-slate-400"> <span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} {t('Steps')}
</span> </span>
</div> </div>
{errors.steps && errors.steps[0]?.keys && ( {errors.steps && errors.steps[0]?.keys && (
@ -248,11 +250,11 @@ export function MacroForm({
theme="light" theme="light"
fullWidth fullWidth
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`} text={t('Add_Step_max',{max:(isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : "")})}
onClick={() => { onClick={() => {
if (isMaxStepsReached) { if (isMaxStepsReached) {
showTemporaryError( showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`, t('You_can_only_add_a_maximum_of_steps_per_macro',{max_step:MAX_STEPS_PER_MACRO})
); );
return; return;
} }
@ -280,11 +282,11 @@ export function MacroForm({
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text={isSubmitting ? "Saving..." : submitText} text={isSubmitting ? t('Saving...') : t(submitText?.replace(' ','_'))}
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} /> <Button size="SM" theme="light" text={t('Cancel')} onClick={onCancel} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu"; import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox"; import { Combobox } from "@/components/Combobox";
@ -25,25 +26,6 @@ const groupedModifiers: Record<string, typeof modifierOptions> = {
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')), Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
}; };
const basePresetDelays = [
{ value: "50", label: "50ms" },
{ value: "100", label: "100ms" },
{ value: "200", label: "200ms" },
{ value: "300", label: "300ms" },
{ value: "500", label: "500ms" },
{ value: "750", label: "750ms" },
{ value: "1000", label: "1000ms" },
{ value: "1500", label: "1500ms" },
{ value: "2000", label: "2000ms" },
];
const PRESET_DELAYS = basePresetDelays.map(delay => {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
return { ...delay, label: "Default" };
}
return delay;
});
interface MacroStep { interface MacroStep {
keys: string[]; keys: string[];
modifiers: string[]; modifiers: string[];
@ -83,6 +65,25 @@ export function MacroStepCard({
isLastStep, isLastStep,
keyboard keyboard
}: MacroStepCardProps) { }: MacroStepCardProps) {
const { t } = useTranslation();
const basePresetDelays = [
{ value: "50", label: t('_ms',{num:50}) },
{ value: "100", label: t('_ms',{num:100}) },
{ value: "200", label: t('_ms',{num:200}) },
{ value: "300", label: t('_ms',{num:300}) },
{ value: "500", label: t('_ms',{num:500}) },
{ value: "750", label: t('_ms',{num:750}) },
{ value: "1000", label: t('_ms',{num:1000}) },
{ value: "1500", label: t('_ms',{num:1500}) },
{ value: "2000", label: t('_ms',{num:2000}) },
];
const PRESET_DELAYS = basePresetDelays.map(delay => {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
return { ...delay, label: t('Default') };
}
return delay;
});
const { keyDisplayMap } = keyboard; const { keyDisplayMap } = keyboard;
const keyOptions = useMemo(() => const keyOptions = useMemo(() =>
@ -105,7 +106,6 @@ export function MacroStepCard({
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase())); return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
} }
}, [keyOptions, keyQuery, step.keys]); }, [keyOptions, keyQuery, step.keys]);
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
@ -137,7 +137,7 @@ export function MacroStepCard({
size="XS" size="XS"
theme="light" theme="light"
className="text-red-500 dark:text-red-400" className="text-red-500 dark:text-red-400"
text="Delete" text={t('Delete')}
LeadingIcon={LuTrash2} LeadingIcon={LuTrash2}
onClick={onDelete} onClick={onDelete}
/> />
@ -147,12 +147,12 @@ export function MacroStepCard({
<div className="space-y-4 mt-2"> <div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" /> <FieldLabel label={t('Modifiers')} />
<div className="inline-flex flex-wrap gap-3"> <div className="inline-flex flex-wrap gap-3">
{Object.entries(groupedModifiers).map(([group, mods]) => ( {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"> <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
<span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400"> <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
{group} {t(group)}
</span> </span>
<div className="flex flex-wrap gap-4 pt-1"> <div className="flex flex-wrap gap-4 pt-1">
{mods.map(option => ( {mods.map(option => (
@ -160,7 +160,7 @@ export function MacroStepCard({
key={option.value} key={option.value}
size="XS" size="XS"
theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"} theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"}
text={option.label.split(' ')[1] || option.label} text={t(option.label.split(' ')[1]) || t(option.label)}
onClick={() => { onClick={() => {
const modifiersArray = ensureArray(step.modifiers); const modifiersArray = ensureArray(step.modifiers);
const isSelected = modifiersArray.includes(option.value); const isSelected = modifiersArray.includes(option.value);
@ -179,7 +179,7 @@ export function MacroStepCard({
<div className="w-full flex flex-col gap-1"> <div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} /> <FieldLabel label={t('Keys')} description={t('Maximum_step_keys_per_step',{max:MAX_KEYS_PER_STEP})} />
</div> </div>
{ensureArray(step.keys) && step.keys.length > 0 && ( {ensureArray(step.keys) && step.keys.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2"> <div className="flex flex-wrap gap-1 pb-2">
@ -214,19 +214,19 @@ export function MacroStepCard({
displayValue={() => keyQuery} displayValue={() => keyQuery}
onInputChange={onKeyQueryChange} onInputChange={onKeyQueryChange}
options={() => filteredKeys} options={() => filteredKeys}
disabledMessage="Max keys reached" disabledMessage={t('Max_keys_reached')}
size="SM" size="SM"
immediate immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP} disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."} placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? t('Max_keys_reached') : t('Search_for_key')}
emptyMessage="No matching keys found" emptyMessage={t('No_matching_keys_found')}
/> />
</div> </div>
</div> </div>
<div className="w-full flex flex-col gap-1"> <div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." /> <FieldLabel label={t('Step_Duration')} description={t('Time_to_wait_before_executing_the_next_step')} />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SelectMenuBasic <SelectMenuBasic

View File

@ -1,8 +1,9 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { ComponentProps } from "react"; import { ComponentProps } from "react";
import { cva, cx } from "cva"; import { cva, cx } from "cva";
import { useTranslation } from "react-i18next";
import { someIterable } from "../utils"; import { someIterable } from "@/utils";
import { GridCard } from "./Card"; import { GridCard } from "./Card";
import MetricsChart from "./MetricsChart"; import MetricsChart from "./MetricsChart";
@ -92,7 +93,6 @@ interface SettingsItemProps {
export function MetricHeader(props: SettingsItemProps) { export function MetricHeader(props: SettingsItemProps) {
const { title, description, badge } = props; const { title, description, badge } = props;
const badgeVariants = cva({ variants: { theme: theme } }); const badgeVariants = cva({ variants: { theme: theme } });
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
@ -143,7 +143,7 @@ export function Metric<T, K extends keyof T>({
// Compute the average value of the metric. // Compute the average value of the metric.
const referenceValue = computeReferenceValue(dataFinal); const referenceValue = computeReferenceValue(dataFinal);
const { t } = useTranslation();
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<MetricHeader <MetricHeader
@ -159,7 +159,7 @@ export function Metric<T, K extends keyof T>({
> >
{!ready ? ( {!ready ? (
<div className="flex flex-col items-center space-y-1"> <div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p> <p className="text-slate-700">{t('Waiting_for_data')}</p>
</div> </div>
) : supportedFinal ? ( ) : supportedFinal ? (
<MetricsChart <MetricsChart
@ -170,7 +170,7 @@ export function Metric<T, K extends keyof T>({
/> />
) : ( ) : (
<div className="flex flex-col items-center space-y-1"> <div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p> <p className="text-black">{t('Metric_not_supported')}</p>
</div> </div>
)} )}
</div> </div>

View File

@ -1,16 +1,18 @@
import { useTranslation } from "react-i18next";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard"; import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation();
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<EmptyCard <EmptyCard
IconElm={ExclamationTriangleIcon} IconElm={ExclamationTriangleIcon}
headline="Not found" headline={t('Not_found')}
description="The page you were looking for does not exist." description={t('The_page_you_were_looking_for_does_not_exist')}
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import StatusCard from "@components/StatusCards"; import StatusCard from "@components/StatusCards";
import {useTranslation} from "react-i18next";
const PeerConnectionStatusMap = { const PeerConnectionStatusMap = {
connected: "Connected", connected: "Connected",
@ -27,7 +28,8 @@ export default function PeerConnectionStatusCard({
state?: RTCPeerConnectionState | null; state?: RTCPeerConnectionState | null;
title?: string; title?: string;
}) { }) {
if (!state) return null; if (!state) return <></>;
const { t } = useTranslation();
const StatusCardProps: StatusProps = { const StatusCardProps: StatusProps = {
connected: { connected: {
statusIndicatorClassName: "bg-green-500 border-green-600", statusIndicatorClassName: "bg-green-500 border-green-600",
@ -55,12 +57,11 @@ export default function PeerConnectionStatusCard({
}, },
}; };
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) return; if (!props) return (<div></div>);
return ( return (
<StatusCard <StatusCard
title={title || "JetKVM Device"} title={title || "JetKVM Device"}
status={PeerConnectionStatusMap[state]} status={t(PeerConnectionStatusMap[state])}
{...StatusCardProps[state]} {...StatusCardProps[state]}
/> />
); );

View File

@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores"; import { AvailableSidebarViews } from "@/hooks/stores";
@ -9,6 +11,7 @@ export default function SidebarHeader({
title: string; title: string;
setSidebarView: (view: AvailableSidebarViews | null) => void; setSidebarView: (view: AvailableSidebarViews | null) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20"> <div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
<div className="min-w-0" style={{ flex: 1 }}> <div className="min-w-0" style={{ flex: 1 }}>
@ -17,7 +20,7 @@ export default function SidebarHeader({
<Button <Button
size="XS" size="XS"
theme="blank" theme="blank"
text="Hide" text={t('Hide')}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<svg <svg
className={cx(className, "rotate-180")} className={cx(className, "rotate-180")}

View File

@ -2,6 +2,7 @@ import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { useTranslation } from "react-i18next";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl"; import { WebglAddon } from "@xterm/addon-webgl";
@ -160,7 +161,7 @@ function Terminal({
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [instance]); }, [instance]);
const { t } = useTranslation();
return ( return (
<div <div
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
@ -191,7 +192,7 @@ function Terminal({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Hide" text={t('Hide')}
LeadingIcon={ChevronDownIcon} LeadingIcon={ChevronDownIcon}
onClick={() => setTerminalType("none")} onClick={() => setTerminalType("none")}
/> />

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
@ -14,14 +15,6 @@ type StatusProps = Record<
statusIndicatorClassName: string; statusIndicatorClassName: string;
} }
>; >;
const USBStateMap: Record<USBStates, string> = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
suspended: "Low power mode",
};
const StatusCardProps: StatusProps = { const StatusCardProps: StatusProps = {
configured: { configured: {
icon: ({ className }) => ( icon: ({ className }) => (
@ -63,7 +56,14 @@ export default function USBStateStatus({
state: USBStates; state: USBStates;
peerConnectionState?: RTCPeerConnectionState | null; peerConnectionState?: RTCPeerConnectionState | null;
}) { }) {
const { t } = useTranslation();
const USBStateMap: Record<USBStates, string> = {
configured: t('Connected'),
attached: t('Connecting'),
addressed: t('Connecting'),
"not attached": t('Disconnected'),
suspended: t('Low_power_mode'),
};
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.warn("Unsupported USB state: ", state); console.warn("Unsupported USB state: ", state);
@ -81,7 +81,7 @@ export default function USBStateStatus({
return ( return (
<StatusCard <StatusCard
title="USB" title="USB"
status="Disconnected" status={t('Disconnected')}
icon={Icon} icon={Icon}
iconClassName={iconClassName} iconClassName={iconClassName}
statusIndicatorClassName={statusIndicatorClassName} statusIndicatorClassName={statusIndicatorClassName}

View File

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
@ -8,7 +9,7 @@ import LoadingSpinner from "./LoadingSpinner";
export default function UpdateInProgressStatusCard() { export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const { t } = useTranslation();
return ( return (
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out"> <div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
<GridCard cardClassName="shadow-xl!"> <GridCard cardClassName="shadow-xl!">
@ -17,12 +18,12 @@ export default function UpdateInProgressStatusCard() {
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} /> <LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1"> <div className="space-y-1">
<div className="text-ellipsis text-sm font-semibold leading-none transition"> <div className="text-ellipsis text-sm font-semibold leading-none transition">
Update in Progress {t('Update_in_Progress')}
</div> </div>
<div className="text-sm leading-none"> <div className="text-sm leading-none">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className={cx("transition")}> <span className={cx("transition")}>
Please don{"'"}t turn off your device... {t('Please_dont_turn_off_your_device')}
</span> </span>
</div> </div>
</div> </div>
@ -32,7 +33,7 @@ export default function UpdateInProgressStatusCard() {
size="SM" size="SM"
className="pointer-events-auto" className="pointer-events-auto"
theme="light" theme="light"
text="View Details" text={t('View_Details')}
onClick={() => navigateTo("/settings/general/update")} onClick={() => navigateTo("/settings/general/update")}
/> />
</div> </div>

View File

@ -1,4 +1,5 @@
import { useCallback , useEffect, useState } from "react"; import { useCallback , useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
@ -31,35 +32,35 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
mass_storage: true, mass_storage: true,
}; };
const usbPresets = [
{
label: "Keyboard, Mouse and Mass Storage",
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
},
},
{
label: "Keyboard Only",
value: "keyboard_only",
config: {
keyboard: true,
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
},
},
{
label: "Custom",
value: "custom",
},
];
export function UsbDeviceSetting() { export function UsbDeviceSetting() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const usbPresets = [
{
label: t('Keyboard_Mouse_and_MassStorage'),
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
},
},
{
label: t('Keyboard_Only'),
value: "keyboard_only",
config: {
keyboard: true,
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
},
},
{
label: t('Custom'),
value: "custom",
},
];
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] = const [usbDeviceConfig, setUsbDeviceConfig] =
@ -71,7 +72,7 @@ export function UsbDeviceSetting() {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error); console.error("Failed to load USB devices:", resp.error);
notifications.error( notifications.error(
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`, t('Failed_to_load_USB_devices_msg',{msg:resp.error.data || t('Unknown_error')})
); );
} else { } else {
const usbConfigState = resp.result as UsbDeviceConfig; const usbConfigState = resp.result as UsbDeviceConfig;
@ -100,7 +101,7 @@ export function UsbDeviceSetting() {
send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => { send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_USB_devices_msg',{msg: resp.error.data || t('Unknown_error')})
); );
setLoading(false); setLoading(false);
return; return;
@ -110,7 +111,7 @@ export function UsbDeviceSetting() {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false); setLoading(false);
syncUsbDeviceConfig(); syncUsbDeviceConfig();
notifications.success(`USB Devices updated`); notifications.success(t('USB_Devices_updated'));
}); });
}, },
[send, syncUsbDeviceConfig], [send, syncUsbDeviceConfig],
@ -153,14 +154,14 @@ export function UsbDeviceSetting() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader <SettingsSectionHeader
title="USB Device" title={t('USB_Device')}
description="USB devices to emulate on the target computer" description={t('USB_devices_to_emulate_on_the_target_computer')}
/> />
<SettingsItem <SettingsItem
loading={loading} loading={loading}
title="Classes" title={t('Classes')}
description="USB device classes in the composite device" description={t('USB_device_classes_in_the_composite_device')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -177,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="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">
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem title="Enable Keyboard" description="Enable Keyboard"> <SettingsItem title={t('Enable_Keyboard')} description={t('Enable_Keyboard')}>
<Checkbox <Checkbox
checked={usbDeviceConfig.keyboard} checked={usbDeviceConfig.keyboard}
onChange={onUsbConfigItemChange("keyboard")} onChange={onUsbConfigItemChange("keyboard")}
@ -186,8 +187,8 @@ export function UsbDeviceSetting() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Enable Absolute Mouse (Pointer)" title={t('Enable_Absolute_Mouse_Pointer')}
description="Enable Absolute Mouse (Pointer)" description={t('Enable_Absolute_Mouse_Pointer')}
> >
<Checkbox <Checkbox
checked={usbDeviceConfig.absolute_mouse} checked={usbDeviceConfig.absolute_mouse}
@ -197,8 +198,8 @@ export function UsbDeviceSetting() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Enable Relative Mouse" title={t('Enable_Relative_Mouse')}
description="Enable Relative Mouse" description={t('Enable_Relative_Mouse')}
> >
<Checkbox <Checkbox
checked={usbDeviceConfig.relative_mouse} checked={usbDeviceConfig.relative_mouse}
@ -208,8 +209,8 @@ export function UsbDeviceSetting() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Enable USB Mass Storage" title={t('Enable_USBMassStorage')}
description="Sometimes it might need to be disabled to prevent issues with certain devices" description={t('Sometimes_it_might_need_to_be_disabled_to_prevent_issues_with_certain_devices')}
> >
<Checkbox <Checkbox
checked={usbDeviceConfig.mass_storage} checked={usbDeviceConfig.mass_storage}
@ -223,13 +224,13 @@ export function UsbDeviceSetting() {
size="SM" size="SM"
loading={loading} loading={loading}
theme="primary" theme="primary"
text="Update USB Classes" text={t('Update_USB_Classes')}
onClick={() => handleUsbConfigChange(usbDeviceConfig)} onClick={() => handleUsbConfigChange(usbDeviceConfig)}
/> />
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Restore to Default" text={t('Restore_to_Default')}
onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)} onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)}
/> />
</div> </div>

View File

@ -1,4 +1,5 @@
import { useMemo , useCallback , useEffect, useState } from "react"; import { useMemo , useCallback , useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -32,33 +33,33 @@ export interface USBConfig {
product: string; product: string;
} }
const usbConfigs = [
{
label: "JetKVM Default",
value: "USB Emulation Device",
},
{
label: "Logitech Universal Adapter",
value: "Logitech USB Input Device",
},
{
label: "Microsoft Wireless MultiMedia Keyboard",
value: "Wireless MultiMedia Keyboard",
},
{
label: "Dell Multimedia Pro Keyboard",
value: "Multimedia Pro Keyboard",
},
];
type UsbConfigMap = Record<string, USBConfig>; type UsbConfigMap = Record<string, USBConfig>;
export function UsbInfoSetting() { export function UsbInfoSetting() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [usbConfigProduct, setUsbConfigProduct] = useState(""); const [usbConfigProduct, setUsbConfigProduct] = useState("");
const [deviceId, setDeviceId] = useState(""); const [deviceId, setDeviceId] = useState("");
const usbConfigs = [
{
label: t('JetKVM_Default'),
value: "USB Emulation Device",
},
{
label: t('Logitech_Universal_Adapter'),
value: "Logitech USB Input Device",
},
{
label: t('Microsoft_Wireless_MultiMedia_Keyboard'),
value: "Wireless MultiMedia Keyboard",
},
{
label: t('Dell_Multimedia_Pro_Keyboard'),
value: "Multimedia Pro Keyboard",
},
];
const usbConfigData: UsbConfigMap = useMemo( const usbConfigData: UsbConfigMap = useMemo(
() => ({ () => ({
"USB Emulation Device": { "USB Emulation Device": {
@ -98,7 +99,7 @@ export function UsbInfoSetting() {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error); console.error("Failed to load USB Config:", resp.error);
notifications.error( notifications.error(
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`, t('Failed_to_load_USB_Config_msg',{msg:resp.error.data || t('Unknown_error')})
); );
} else { } else {
const usbConfigState = resp.result as UsbConfigState; const usbConfigState = resp.result as UsbConfigState;
@ -117,7 +118,7 @@ export function UsbInfoSetting() {
send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => { send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_USB_Config_msg',{msg:resp.error.data || t('Unknown_error')})
); );
setLoading(false); setLoading(false);
return; return;
@ -127,7 +128,7 @@ export function UsbInfoSetting() {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false); setLoading(false);
notifications.success( notifications.success(
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`, t('USB_Config_set_to_msg',{m:usbConfig.manufacturer,p:usbConfig.product})
); );
syncUsbConfigProduct(); syncUsbConfigProduct();
@ -140,7 +141,7 @@ export function UsbInfoSetting() {
send("getDeviceID", {}, (resp: JsonRpcResponse) => { send("getDeviceID", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
return notifications.error( return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`, t('Failed_to_get_device_ID_msg',{msg: resp.error.data || t('Unknown_error')})
); );
} }
setDeviceId(resp.result as string); setDeviceId(resp.result as string);
@ -153,8 +154,8 @@ export function UsbInfoSetting() {
<Fieldset disabled={loading} className="space-y-4"> <Fieldset disabled={loading} className="space-y-4">
<SettingsItem <SettingsItem
loading={loading} loading={loading}
title="Identifiers" title={t('Identifiers')}
description="USB device identifiers exposed to the target computer" description={t('USB_device_identifiers_exposed_to_the_target_computer')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -206,6 +207,7 @@ function USBConfigDialog({
}); });
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const syncUsbConfig = useCallback(() => { const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, (resp: JsonRpcResponse) => { send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
@ -247,38 +249,38 @@ function USBConfigDialog({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InputFieldWithLabel <InputFieldWithLabel
required required
label="Vendor ID" label={t('Vendor_ID')}
placeholder="Enter Vendor ID" placeholder={t('Enter_Vendor_ID')}
pattern="^0[xX][\da-fA-F]{4}$" pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.vendor_id} defaultValue={usbConfigState?.vendor_id}
onChange={e => handleUsbVendorIdChange(e.target.value)} onChange={e => handleUsbVendorIdChange(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
required required
label="Product ID" label={t('Product_ID')}
placeholder="Enter Product ID" placeholder={t('Enter_Product_ID')}
pattern="^0[xX][\da-fA-F]{4}$" pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.product_id} defaultValue={usbConfigState?.product_id}
onChange={e => handleUsbProductIdChange(e.target.value)} onChange={e => handleUsbProductIdChange(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
required required
label="Serial Number" label={t('Serial_Number')}
placeholder="Enter Serial Number" placeholder={t('Enter_Serial_Number')}
defaultValue={usbConfigState?.serial_number} defaultValue={usbConfigState?.serial_number}
onChange={e => handleUsbSerialChange(e.target.value)} onChange={e => handleUsbSerialChange(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
required required
label="Manufacturer" label={t('Manufacturer')}
placeholder="Enter Manufacturer" placeholder={t('Enter_Manufacturer')}
defaultValue={usbConfigState?.manufacturer} defaultValue={usbConfigState?.manufacturer}
onChange={e => handleUsbManufacturer(e.target.value)} onChange={e => handleUsbManufacturer(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
required required
label="Product Name" label={t('Product_Name')}
placeholder="Enter Product Name" placeholder={t('Enter_Product_Name')}
defaultValue={usbConfigState?.product} defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)} onChange={e => handleUsbProduct(e.target.value)}
/> />
@ -288,13 +290,13 @@ function USBConfigDialog({
loading={loading} loading={loading}
size="SM" size="SM"
theme="primary" theme="primary"
text="Update USB Identifiers" text={t('Update_USB_Identifiers')}
onClick={() => onSetUsbConfig(usbConfigState)} onClick={() => onSetUsbConfig(usbConfigState)}
/> />
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Restore to Default" text={t('Restore_to_Default')}
onClick={onRestoreToDefault} onClick={onRestoreToDefault}
/> />
</div> </div>

View File

@ -4,6 +4,7 @@ import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu"; import { LuPlay } from "react-icons/lu";
import { BsMouseFill } from "react-icons/bs"; import { BsMouseFill } from "react-icons/bs";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
@ -27,6 +28,7 @@ interface LoadingOverlayProps {
} }
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) { export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
const { t } = useTranslation();
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
@ -46,7 +48,7 @@ export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" /> <LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div> </div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300"> <p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream... {t('Loading_video_stream')}...
</p> </p>
</div> </div>
</OverlayContent> </OverlayContent>
@ -99,6 +101,7 @@ export function ConnectionFailedOverlay({
show, show,
setupPeerConnection, setupPeerConnection,
}: ConnectionErrorOverlayProps) { }: ConnectionErrorOverlayProps) {
const { t } = useTranslation();
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
@ -118,26 +121,26 @@ export function ConnectionFailedOverlay({
<div className="text-left text-sm text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <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">{t('Connection_Issue_Detected')}</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li> <li>{t('Verify_device_powered_and_connected')}</li>
<li>Check all cable connections for any loose or damaged wires</li> <li>{t('Check_cable_loose_damaged')}</li>
<li>Ensure your network connection is stable and active</li> <li>{t('Verify_device_powered_and_connected')}</li>
<li>Try restarting both the device and your computer</li> <li>{t('Try_restarting_both_device_computer')}</li>
</ul> </ul>
</div> </div>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<LinkButton <LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="primary" theme="primary"
text="Troubleshooting Guide" text={t('Troubleshooting_Guide')}
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
size="SM" size="SM"
/> />
<Button <Button
onClick={() => setupPeerConnection()} onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon} LeadingIcon={ArrowPathIcon}
text="Try again" text={t('Try_again')}
size="SM" size="SM"
theme="light" theme="light"
/> />
@ -159,6 +162,7 @@ interface PeerConnectionDisconnectedOverlay {
export function PeerConnectionDisconnectedOverlay({ export function PeerConnectionDisconnectedOverlay({
show, show,
}: PeerConnectionDisconnectedOverlay) { }: PeerConnectionDisconnectedOverlay) {
const { t } = useTranslation();
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
@ -179,11 +183,12 @@ export function PeerConnectionDisconnectedOverlay({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <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">Connection Issue Detected</h2>
<h2 className="text-xl font-bold">{t('Connection_Issue_Detected')}</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li> <li>{t('Verify_device_powered_and_connected')}</li>
<li>Check all cable connections for any loose or damaged wires</li> <li>{t('Check_cable_loose_damaged')}</li>
<li>Ensure your network connection is stable and active</li> <li>{t('Verify_device_powered_and_connected')}</li>
<li>Try restarting both the device and your computer</li> <li>{t('Try_restarting_both_device_computer')}</li>
</ul> </ul>
</div> </div>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
@ -191,7 +196,7 @@ export function PeerConnectionDisconnectedOverlay({
<div className="flex items-center gap-x-2 p-4"> <div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" /> <LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300"> <p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection... {t('Retrying_connection')}...
</p> </p>
</div> </div>
</Card> </Card>
@ -214,7 +219,7 @@ interface HDMIErrorOverlayProps {
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal"; const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range"; const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
const { t } = useTranslation();
return ( return (
<> <>
<AnimatePresence> <AnimatePresence>
@ -235,15 +240,14 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="text-left text-sm text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <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">{t('No_HDMI_signal_detected')}</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li> <li>{t('Ensure_the_HDMI_cable_securely_connected_at_both_ends')}</li>
<li> <li>
Ensure source device is powered on and outputting a signal {t('Ensure_source_device_is_powered_on_and_outputting_a_signal')}
</li> </li>
<li> <li>
If using an adapter, ensure it&apos;s compatible and functioning {t('If_using_an_adapter')}
correctly
</li> </li>
</ul> </ul>
</div> </div>
@ -251,7 +255,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<LinkButton <LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="light"
text="Learn more" text={t('Learn_more')}
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
size="SM" size="SM"
/> />
@ -282,18 +286,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="text-left text-sm text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <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">{t('HDMI_signal_error_detected')}</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li> <li>{t('A_loose_or_faulty_HDMI_connection')}</li>
<li>Incompatible resolution or refresh rate settings</li> <li>{t('Incompatible_resolution_or_refresh_rate_settings')}</li>
<li>Issues with the source device&apos;s HDMI output</li> <li>{t('Issues_with_the_source_devices_HDMI_output')}</li>
</ul> </ul>
</div> </div>
<div> <div>
<LinkButton <LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="light"
text="Learn more" text={t('Learn_more')}
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
size="SM" size="SM"
/> />
@ -318,6 +322,7 @@ export function NoAutoplayPermissionsOverlay({
show, show,
onPlayClick, onPlayClick,
}: NoAutoplayPermissionsOverlayProps) { }: NoAutoplayPermissionsOverlayProps) {
const { t } = useTranslation();
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
@ -334,7 +339,7 @@ export function NoAutoplayPermissionsOverlay({
<OverlayContent> <OverlayContent>
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-2xl font-extrabold text-black dark:text-white"> <h2 className="text-2xl font-extrabold text-black dark:text-white">
Autoplay permissions required {t('Autoplay_permissions_required')}
</h2> </h2>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
@ -343,13 +348,13 @@ export function NoAutoplayPermissionsOverlay({
size="MD" size="MD"
theme="primary" theme="primary"
LeadingIcon={LuPlay} LeadingIcon={LuPlay}
text="Manually start stream" text={t('Manually_start_stream')}
onClick={onPlayClick} onClick={onPlayClick}
/> />
</div> </div>
<div className="text-xs text-slate-600 dark:text-slate-400"> <div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay {t('Please_adjust_browser_settings_to_enable_autoplay')}
</div> </div>
</div> </div>
</div> </div>
@ -365,6 +370,7 @@ interface PointerLockBarProps {
} }
export function PointerLockBar({ show }: PointerLockBarProps) { export function PointerLockBar({ show }: PointerLockBarProps) {
const { t } = useTranslation();
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{show ? ( {show ? (
@ -381,7 +387,7 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
<span className="text-sm text-black dark:text-white"> <span className="text-sm text-black dark:text-white">
Click on the video to enable mouse control {t('Click_video_enable_mouse_control')}
</span> </span>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard"; import Keyboard from "react-simple-keyboard";
import { LuKeyboard } from "react-icons/lu"; import { LuKeyboard } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import Card from "@components/Card"; import Card from "@components/Card";
// eslint-disable-next-line import/order // eslint-disable-next-line import/order
@ -200,7 +201,7 @@ function KeyboardWrapper() {
}, },
[executeMacro, handleKeyPress, keyNamesForDownKeys], [executeMacro, handleKeyPress, keyNamesForDownKeys],
); );
const { t } = useTranslation();
return ( return (
<div <div
className="transition-all duration-500 ease-in-out" className="transition-all duration-500 ease-in-out"
@ -244,20 +245,20 @@ function KeyboardWrapper() {
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Detach" text={t('Detach')}
onClick={() => setAttachedVirtualKeyboardVisibility(false)} onClick={() => setAttachedVirtualKeyboardVisibility(false)}
/> />
) : ( ) : (
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Attach" text={t('Attach')}
onClick={() => setAttachedVirtualKeyboardVisibility(true)} onClick={() => setAttachedVirtualKeyboardVisibility(true)}
/> />
)} )}
</div> </div>
<h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300"> <h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300">
Virtual Keyboard {t('Virtual_Keyboard')}
</h2> </h2>
<div className="absolute right-2 flex items-center gap-x-2"> <div className="absolute right-2 flex items-center gap-x-2">
<div className="hidden md:flex gap-x-2 items-center"> <div className="hidden md:flex gap-x-2 items-center">
@ -274,7 +275,7 @@ function KeyboardWrapper() {
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Hide" text={t('Hide')}
LeadingIcon={ChevronDownIcon} LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboardEnabled(false)} onClick={() => setVirtualKeyboardEnabled(false)}
/> />

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts"; import { useResizeObserver } from "usehooks-ts";
import { useTranslation } from "react-i18next";
import VirtualKeyboard from "@components/VirtualKeyboard"; import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar"; import Actionbar from "@components/ActionBar";
@ -165,13 +166,13 @@ export default function WebRTCVideo() {
useEffect(() => { useEffect(() => {
if (!isPointerLockPossible || !videoElm.current) return; if (!isPointerLockPossible || !videoElm.current) return;
const { t } = useTranslation();
const handlePointerLockChange = () => { const handlePointerLockChange = () => {
if (document.pointerLockElement) { if (document.pointerLockElement) {
notifications.success("Pointer lock Enabled, press escape to unlock"); notifications.success(t('Pointer_lock_Enabled_press_escape_to_unlock'));
setIsPointerLockActive(true); setIsPointerLockActive(true);
} else { } else {
notifications.success("Pointer lock Disabled"); notifications.success(t('Pointer_lock_Disabled'));
setIsPointerLockActive(false); setIsPointerLockActive(false);
} }
}; };
@ -479,7 +480,6 @@ export default function WebRTCVideo() {
} }
return code; return code;
} }
return ( return (
<div className="grid h-full w-full grid-rows-(--grid-layout)"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">

View File

@ -1,5 +1,6 @@
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu"; import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
@ -7,8 +8,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications"; import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
interface ATXState { interface ATXState {
@ -28,13 +28,14 @@ export function ATXPowerControl() {
setAtxState(resp.params as ATXState); setAtxState(resp.params as ATXState);
} }
}); });
const { t } = useTranslation();
// Request initial state // Request initial state
useEffect(() => { useEffect(() => {
send("getATXState", {}, (resp: JsonRpcResponse) => { send("getATXState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`, t('Failed_to_get_ATX_state_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -57,7 +58,7 @@ export function ATXPowerControl() {
send("setATXPowerAction", { action: "power-long" }, (resp: JsonRpcResponse) => { send("setATXPowerAction", { action: "power-long" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, t('Failed_to_send_ATX_power_action_msg',{msg:resp.error.data || t('Unknown_error')})
); );
} }
setIsPowerPressed(false); setIsPowerPressed(false);
@ -78,7 +79,7 @@ export function ATXPowerControl() {
send("setATXPowerAction", { action: "power-short" }, (resp: JsonRpcResponse) => { send("setATXPowerAction", { action: "power-short" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, t('Failed_to_send_ATX_power_action_msg',{msg:resp.error.data || t('Unknown_error')})
); );
} }
}); });
@ -98,8 +99,8 @@ export function ATXPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="ATX Power Control" title={t('ATX_Power_Control')}
description="Control your ATX power settings" description={t('Control_your_ATX_power_settings')}
/> />
{atxState === null ? ( {atxState === null ? (
@ -115,7 +116,7 @@ export function ATXPowerControl() {
size="SM" size="SM"
theme="light" theme="light"
LeadingIcon={LuPower} LeadingIcon={LuPower}
text="Power" text={t('Power')}
onMouseDown={() => handlePowerPress(true)} onMouseDown={() => handlePowerPress(true)}
onMouseUp={() => handlePowerPress(false)} onMouseUp={() => handlePowerPress(false)}
onMouseLeave={() => handlePowerPress(false)} onMouseLeave={() => handlePowerPress(false)}
@ -125,12 +126,12 @@ export function ATXPowerControl() {
size="SM" size="SM"
theme="light" theme="light"
LeadingIcon={LuRotateCcw} LeadingIcon={LuRotateCcw}
text="Reset" text={t('Reset')}
onClick={() => { onClick={() => {
send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => { send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, t('Failed_to_send_ATX_power_action_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -150,7 +151,7 @@ export function ATXPowerControl() {
atxState?.power ? "text-green-600" : "text-slate-300" atxState?.power ? "text-green-600" : "text-slate-300"
}`} }`}
/> />
Power LED {t('Power_LED')}
</span> </span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -161,7 +162,7 @@ export function ATXPowerControl() {
atxState?.hdd ? "text-blue-400" : "text-slate-300" atxState?.hdd ? "text-blue-400" : "text-slate-300"
}`} }`}
/> />
HDD LED {t('HDD_LED')}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { LuPower } from "react-icons/lu"; import { LuPower } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
@ -17,16 +18,16 @@ interface DCPowerState {
power: number; power: number;
restoreState: number; restoreState: number;
} }
export function DCPowerControl() { export function DCPowerControl() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [powerState, setPowerState] = useState<DCPowerState | null>(null); const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => { const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, (resp: JsonRpcResponse) => { send("getDCPowerState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`, t('Failed_to_get_DC_power_state_msg',{msg:resp.error.data||t('Unknown_error')})
); );
return; return;
} }
@ -38,7 +39,7 @@ export function DCPowerControl() {
send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => { send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_DC_power_state_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -50,7 +51,7 @@ export function DCPowerControl() {
send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => { send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_DC_power_state_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -70,8 +71,8 @@ export function DCPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="DC Power Control" title={t('DC_Power_Control')}
description="Control your DC power settings" description={t('Control_your_DC_power_settings')}
/> />
{powerState === null ? ( {powerState === null ? (
@ -87,7 +88,7 @@ export function DCPowerControl() {
size="SM" size="SM"
theme="light" theme="light"
LeadingIcon={LuPower} LeadingIcon={LuPower}
text="Power On" text={t('Power_On')}
onClick={() => handlePowerToggle(true)} onClick={() => handlePowerToggle(true)}
disabled={powerState.isOn} disabled={powerState.isOn}
/> />
@ -95,7 +96,7 @@ export function DCPowerControl() {
size="SM" size="SM"
theme="light" theme="light"
LeadingIcon={LuPower} LeadingIcon={LuPower}
text="Power Off" text={t('Power_Off')}
disabled={!powerState.isOn} disabled={!powerState.isOn}
onClick={() => handlePowerToggle(false)} onClick={() => handlePowerToggle(false)}
/> />
@ -104,13 +105,13 @@ export function DCPowerControl() {
<div className="flex items-center"> <div className="flex items-center">
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="Restore Power Loss" label={t('Restore_Power_Loss')}
value={powerState.restoreState} value={powerState.restoreState}
onChange={e => handleRestoreChange(parseInt(e.target.value))} onChange={e => handleRestoreChange(parseInt(e.target.value))}
options={[ options={[
{ value: '0', label: "Power OFF" }, { value: '0', label: t('Power_Off') },
{ value: '1', label: "Power ON" }, { value: '1', label: t('Power_On') },
{ value: '2', label: "Last State" }, { value: '2', label: t('Last_State') },
]} ]}
/> />
</div> </div>
@ -120,19 +121,19 @@ export function DCPowerControl() {
{/* Status Display */} {/* Status Display */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<FieldLabel label="Voltage" /> <FieldLabel label={t('Voltage')} />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100"> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.voltage.toFixed(1)}V {powerState.voltage.toFixed(1)}V
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<FieldLabel label="Current" /> <FieldLabel label={t('Current')} />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100"> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.current.toFixed(1)}A {powerState.current.toFixed(1)}A
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<FieldLabel label="Power" /> <FieldLabel label={t('Power_Watt')} />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100"> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.power.toFixed(1)}W {powerState.power.toFixed(1)}W
</p> </p>

View File

@ -1,5 +1,6 @@
import { LuTerminal } from "react-icons/lu"; import { LuTerminal } from "react-icons/lu";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
@ -18,6 +19,7 @@ interface SerialSettings {
export function SerialConsole() { export function SerialConsole() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [settings, setSettings] = useState<SerialSettings>({ const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600", baudRate: "9600",
dataBits: "8", dataBits: "8",
@ -29,7 +31,7 @@ export function SerialConsole() {
send("getSerialSettings", {}, (resp: JsonRpcResponse) => { send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`, t('Failed_to_get_serial_settings_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -42,7 +44,7 @@ export function SerialConsole() {
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => { send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`, t('Failed_to_update_serial_settings_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -54,8 +56,8 @@ export function SerialConsole() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Serial Console" title={t('Serial_Console')}
description="Configure your serial console settings" description={t('Configure_your_serial_console_settings')}
/> />
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0">
@ -66,7 +68,7 @@ export function SerialConsole() {
size="SM" size="SM"
theme="primary" theme="primary"
LeadingIcon={LuTerminal} LeadingIcon={LuTerminal}
text="Open Console" text={t('Open_Console')}
onClick={() => { onClick={() => {
setTerminalType("serial"); setTerminalType("serial");
console.log("Opening serial console with settings: ", settings); console.log("Opening serial console with settings: ", settings);
@ -77,7 +79,7 @@ export function SerialConsole() {
{/* Settings */} {/* Settings */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<SelectMenuBasic <SelectMenuBasic
label="Baud Rate" label={t('Baud_Rate')}
options={[ options={[
{ label: "1200", value: "1200" }, { label: "1200", value: "1200" },
{ label: "2400", value: "2400" }, { label: "2400", value: "2400" },
@ -93,7 +95,7 @@ export function SerialConsole() {
/> />
<SelectMenuBasic <SelectMenuBasic
label="Data Bits" label={t('Data_Bits')}
options={[ options={[
{ label: "8", value: "8" }, { label: "8", value: "8" },
{ label: "7", value: "7" }, { label: "7", value: "7" },
@ -103,7 +105,7 @@ export function SerialConsole() {
/> />
<SelectMenuBasic <SelectMenuBasic
label="Stop Bits" label={t('Stop_Bits')}
options={[ options={[
{ label: "1", value: "1" }, { label: "1", value: "1" },
{ label: "1.5", value: "1.5" }, { label: "1.5", value: "1.5" },
@ -114,11 +116,11 @@ export function SerialConsole() {
/> />
<SelectMenuBasic <SelectMenuBasic
label="Parity" label={t('Parity')}
options={[ options={[
{ label: "None", value: "none" }, { label: t("None"), value: "none" },
{ label: "Even", value: "even" }, { label: t("Even"), value: "even" },
{ label: "Odd", value: "odd" }, { label: t("Odd"), value: "odd" },
]} ]}
value={settings.parity} value={settings.parity}
onChange={e => handleSettingChange("parity", e.target.value)} onChange={e => handleSettingChange("parity", e.target.value)}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu"; import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
@ -17,132 +18,132 @@ interface Extension {
icon: React.ElementType; icon: React.ElementType;
} }
const AVAILABLE_EXTENSIONS: Extension[] = [ export function ExtensionPopover() {
{ const {send} = useJsonRpc();
id: "atx-power", const {t} = useTranslation();
name: "ATX Power Control", const AVAILABLE_EXTENSIONS: Extension[] = [
description: "Control your ATX Power extension", {
icon: LuPower, id: "atx-power",
}, name: t('ATX_Power_Control'),
{ description: t('Control_your_ATX_Power_extension'),
id: "dc-power", icon: LuPower,
name: "DC Power Control", },
description: "Control your DC Power extension", {
icon: LuPlugZap, id: "dc-power",
}, name: t('DC_Power_Control'),
{ description: t('Control_your_DC_Power_extension'),
id: "serial-console", icon: LuPlugZap,
name: "Serial Console", },
description: "Access your serial console extension", {
icon: LuTerminal, id: "serial-console",
}, name: t('Serial_Console'),
]; description: t('Access_your_serial_console_extension'),
icon: LuTerminal,
},
];
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
export default function ExtensionPopover() { // Load active extension on component mount
const { send } = useJsonRpc(); useEffect(() => {
const [activeExtension, setActiveExtension] = useState<Extension | null>(null); send("getActiveExtension", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const extensionId = resp.result as string;
if (extensionId) {
const extension = AVAILABLE_EXTENSIONS.find(ext => ext.id === extensionId);
if (extension) {
setActiveExtension(extension);
}
}
});
}, [send]);
// Load active extension on component mount const handleSetActiveExtension = (extension: Extension | null) => {
useEffect(() => { send("setActiveExtension", {extensionId: extension?.id || ""}, (resp: JsonRpcResponse) => {
send("getActiveExtension", {}, (resp: JsonRpcResponse) => { if ("error" in resp) {
if ("error" in resp) return; notifications.error(
const extensionId = resp.result as string; t('Failed_to_set_active_extension_msg', {msg: resp.error.data || t('Unknown_error')})
if (extensionId) { );
const extension = AVAILABLE_EXTENSIONS.find(ext => ext.id === extensionId); return;
if (extension) { }
setActiveExtension(extension); setActiveExtension(extension);
});
};
const renderActiveExtension = () => {
switch (activeExtension?.id) {
case "atx-power":
return <ATXPowerControl/>;
case "dc-power":
return <DCPowerControl/>;
case "serial-console":
return <SerialConsole/>;
default:
return null;
} }
} };
});
}, [send]);
const handleSetActiveExtension = (extension: Extension | null) => { return (
send("setActiveExtension", { extensionId: extension?.id || "" }, (resp: JsonRpcResponse) => { <GridCard>
if ("error" in resp) { <div className="space-y-4 p-4 py-3">
notifications.error( <div className="grid h-full grid-rows-(--grid-headerBody)">
`Failed to set active extension: ${resp.error.data || "Unknown error"}`, <div className="space-y-4">
); {activeExtension ? (
return; // Extension Control View
} <div className="space-y-4">
setActiveExtension(extension); {renderActiveExtension()}
});
};
const renderActiveExtension = () => { <div
switch (activeExtension?.id) { className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
case "atx-power": style={{
return <ATXPowerControl />; animationDuration: "0.7s",
case "dc-power": animationDelay: "0.2s",
return <DCPowerControl />; }}
case "serial-console": >
return <SerialConsole />; <Button
default: size="SM"
return null; theme="light"
} text={t('Unload_Extension')}
}; onClick={() => handleSetActiveExtension(null)}
/>
return ( </div>
<GridCard> </div>
<div className="space-y-4 p-4 py-3"> ) : (
<div className="grid h-full grid-rows-(--grid-headerBody)"> // Extensions List View
<div className="space-y-4"> <div className="space-y-4">
{activeExtension ? ( <SettingsPageHeader
// Extension Control View title={t('Extensions')}
<div className="space-y-4"> description={t('Load_and_manage_your_extensions')}
{renderActiveExtension()} />
<Card className="animate-fadeIn opacity-0">
<div <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2" {AVAILABLE_EXTENSIONS.map(extension => (
style={{ <div
animationDuration: "0.7s", key={extension.id}
animationDelay: "0.2s", className="flex items-center justify-between p-3"
}} >
> <div className="space-y-0.5">
<Button <p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
size="SM" {extension.name}
theme="light" </p>
text="Unload Extension" <p className="text-sm text-slate-600 dark:text-slate-400">
onClick={() => handleSetActiveExtension(null)} {extension.description}
/> </p>
</div>
<Button
size="XS"
theme="light"
text={t('Load')}
onClick={() => handleSetActiveExtension(extension)}
/>
</div>
))}
</div>
</Card>
</div>
)}
</div>
</div> </div>
</div> </div>
) : ( </GridCard>
// Extensions List View );
<div className="space-y-4">
<SettingsPageHeader
title="Extensions"
description="Load and manage your extensions"
/>
<Card className="animate-fadeIn opacity-0" >
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => (
<div
key={extension.id}
className="flex items-center justify-between p-3"
>
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
{extension.name}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{extension.description}
</p>
</div>
<Button
size="XS"
theme="light"
text="Load"
onClick={() => handleSetActiveExtension(extension)}
/>
</div>
))}
</div>
</Card>
</div>
)}
</div>
</div>
</div>
</GridCard>
);
} }

View File

@ -7,6 +7,7 @@ import {
} from "react-icons/lu"; } from "react-icons/lu";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
@ -19,6 +20,7 @@ import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore(); useMountMediaStore();
@ -26,7 +28,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => { send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) { if ("error" in response) {
notifications.error( notifications.error(
`Failed to get virtual media state: ${response.error.message}`, t('Failed_to_get_virtual_media_state_msg',{msg:response.error.message})
); );
} else { } else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState); setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
@ -37,7 +39,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const handleUnmount = () => { const handleUnmount = () => {
send("unmountImage", {}, (response: JsonRpcResponse) => { send("unmountImage", {}, (response: JsonRpcResponse) => {
if ("error" in response) { if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`); notifications.error(t('Failed_to_unmount_image_msg',{msg:response.error.message}));
} else { } else {
syncRemoteVirtualMediaState(); syncRemoteVirtualMediaState();
} }
@ -57,10 +59,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white"> <h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media {t('No_mounted_media')}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started {t('Add_a_file_to_get_started')}
</p> </p>
</div> </div>
</div> </div>
@ -81,7 +83,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card> </Card>
</div> </div>
<h3 className="text-base font-semibold text-black dark:text-white"> <h3 className="text-base font-semibold text-black dark:text-white">
Streaming from URL {t('Streaming_from_URL')}
</h3> </h3>
<p className="truncate text-sm text-slate-900 dark:text-slate-100"> <p className="truncate text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(url, 55)} {formatters.truncateMiddle(url, 55)}
@ -105,7 +107,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card> </Card>
</div> </div>
<h3 className="text-base font-semibold text-black dark:text-white"> <h3 className="text-base font-semibold text-black dark:text-white">
Mounted from JetKVM Storage {t('Mounted_from_JetKVM_Storage')}
</h3> </h3>
<p className="text-sm text-slate-900 dark:text-slate-100"> <p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)} {formatters.truncateMiddle(path, 50)}
@ -138,8 +140,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Virtual Media" title={t('Virtual_Media')}
description="Mount an image to boot from or install an operating system." description={t('Mount_an_image_to_boot_from_or_install_an_operating_system')}
/> />
<div <div
@ -163,7 +165,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
{remoteVirtualMediaState ? ( {remoteVirtualMediaState ? (
<div className="flex select-none items-center justify-between text-xs"> <div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300"> <div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "} <span>{t('Mounted_as')}</span>{" "}
<span className="font-semibold"> <span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"} {remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span> </span>
@ -173,7 +175,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button <Button
size="SM" size="SM"
theme="blank" theme="blank"
text="Close" text={t('Close')}
onClick={() => { onClick={() => {
close(); close();
}} }}
@ -181,7 +183,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Unmount" text={t('Unmount')}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<svg <svg
className={`${className} h-2.5 w-2.5 shrink-0`} className={`${className} h-2.5 w-2.5 shrink-0`}
@ -227,7 +229,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button <Button
size="SM" size="SM"
theme="blank" theme="blank"
text="Close" text={t('Close')}
onClick={() => { onClick={() => {
close(); close();
}} }}
@ -235,7 +237,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Add New Media" text={t('Add_New_Media')}
onClick={() => { onClick={() => {
setModalView("mode"); setModalView("mode");
navigateTo("/mount"); navigateTo("/mount");

View File

@ -2,6 +2,7 @@ import { useClose } from "@headlessui/react";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu"; import { LuCornerDownLeft } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
@ -24,6 +25,7 @@ export default function PasteModal() {
const { setDisableVideoFocusTrap } = useUiStore(); const { setDisableVideoFocusTrap } = useUiStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const { executeMacro, cancelExecuteMacro } = useKeyboard(); const { executeMacro, cancelExecuteMacro } = useKeyboard();
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
@ -104,7 +106,7 @@ export default function PasteModal() {
} }
} catch (error) { } catch (error) {
console.error("Failed to paste text:", error); console.error("Failed to paste text:", error);
notifications.error("Failed to paste text"); notifications.error(t('Failed_to_paste_text'));
} }
}, [selectedKeyboard, executeMacro, delay]); }, [selectedKeyboard, executeMacro, delay]);
@ -121,8 +123,8 @@ export default function PasteModal() {
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Paste text" title={t('Paste_text')}
description="Paste text from your client to the remote host" description={t('Paste_text_from_your_client_to_the_remote_host')}
/> />
<div <div
@ -141,7 +143,7 @@ export default function PasteModal() {
> >
<TextAreaWithLabel <TextAreaWithLabel
ref={TextAreaRef} ref={TextAreaRef}
label="Paste from host" label={t('Paste_from_host')}
rows={4} rows={4}
onKeyUp={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}
maxLength={pasteMaxLength} maxLength={pasteMaxLength}
@ -184,8 +186,8 @@ export default function PasteModal() {
<div className={cx("text-xs text-slate-600 dark:text-slate-400", delayClassName)}> <div className={cx("text-xs text-slate-600 dark:text-slate-400", delayClassName)}>
<InputFieldWithLabel <InputFieldWithLabel
type="number" type="number"
label="Delay between keys" label={t('Delay_between_keys')}
placeholder="Delay between keys" placeholder={t('Delay_between_keys')}
min={50} min={50}
max={65534} max={65534}
value={delayValue} value={delayValue}
@ -197,14 +199,14 @@ export default function PasteModal() {
<div className="mt-2 flex items-center gap-x-2"> <div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" /> <ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs 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 {t('Delay_must_be_between_50_and_65534')}
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {selectedKeyboard.isoCode}- {t('Sending_text_using_keyboard_layout')}: {selectedKeyboard.isoCode}-
{selectedKeyboard.name} {selectedKeyboard.name}
</p> </p>
</div> </div>
@ -222,7 +224,7 @@ export default function PasteModal() {
<Button <Button
size="SM" size="SM"
theme="blank" theme="blank"
text="Cancel" text={t('Cancel')}
onClick={() => { onClick={() => {
onCancelPasteMode(); onCancelPasteMode();
close(); close();
@ -231,7 +233,7 @@ export default function PasteModal() {
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Confirm Paste" text={t('Confirm_Paste')}
disabled={isPasteInProgress} disabled={isPasteInProgress}
onClick={onConfirmPaste} onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft} LeadingIcon={LuCornerDownLeft}

View File

@ -1,5 +1,6 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { LuPlus, LuArrowLeft } from "react-icons/lu"; import { LuPlus, LuArrowLeft } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { InputFieldWithLabel } from "@/components/InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
@ -22,7 +23,7 @@ export default function AddDeviceForm({
const nameInputRef = useRef<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null); const macInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div <div
@ -34,8 +35,8 @@ export default function AddDeviceForm({
> >
<InputFieldWithLabel <InputFieldWithLabel
ref={nameInputRef} ref={nameInputRef}
placeholder="Plex Media Server" placeholder={t('Plex_Media_Server')}
label="Device Name" label={t('Device_Name')}
required required
onChange={e => { onChange={e => {
setIsDeviceNameValid(e.target.validity.valid); setIsDeviceNameValid(e.target.validity.valid);
@ -46,7 +47,7 @@ export default function AddDeviceForm({
<InputFieldWithLabel <InputFieldWithLabel
ref={macInputRef} ref={macInputRef}
placeholder="00:b0:d0:63:c2:26" placeholder="00:b0:d0:63:c2:26"
label="MAC Address" label={t('MAC_Address')}
onKeyUp={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}
required required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$" 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 <Button
size="SM" size="SM"
theme="light" theme="light"
text="Back" text={t('Back')}
LeadingIcon={LuArrowLeft} LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)} onClick={() => setShowAddForm(false)}
/> />
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Save Device" text={t('Save_Device')}
disabled={!isDeviceNameValid || !isMacAddressValid} disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => { onClick={() => {
const deviceName = nameInputRef.current?.value || ""; const deviceName = nameInputRef.current?.value || "";

View File

@ -1,4 +1,5 @@
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu"; import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import Card from "@/components/Card"; import Card from "@/components/Card";
@ -26,6 +27,7 @@ export default function DeviceList({
onCancelWakeOnLanModal, onCancelWakeOnLanModal,
setShowAddForm, setShowAddForm,
}: DeviceListProps) { }: DeviceListProps) {
const { t } = useTranslation();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0">
@ -46,7 +48,7 @@ export default function DeviceList({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Wake" text={t('Wake')}
LeadingIcon={LuSend} LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)} onClick={() => onSendMagicPacket(device.macAddress)}
/> />
@ -55,7 +57,7 @@ export default function DeviceList({
theme="danger" theme="danger"
LeadingIcon={LuTrash2} LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)} onClick={() => onDeleteDevice(index)}
aria-label="Delete device" aria-label={t('Delete_device')}
/> />
</div> </div>
</div> </div>
@ -69,11 +71,11 @@ export default function DeviceList({
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} /> <Button size="SM" theme="blank" text={t('Close')} onClick={onCancelWakeOnLanModal} />
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Add New Device" text={t('Add_New_Device')}
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
/> />

View File

@ -1,5 +1,6 @@
import { PlusCircleIcon } from "@heroicons/react/16/solid"; import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
@ -11,6 +12,7 @@ export default function EmptyStateCard({
onCancelWakeOnLanModal: () => void; onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void; setShowAddForm: (show: boolean) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="select-none space-y-4"> <div className="select-none space-y-4">
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0">
@ -25,10 +27,10 @@ export default function EmptyStateCard({
</Card> </Card>
</div> </div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white"> <h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added {t('No_devices_added')}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN {t('Add_a_device_to_start_using_Wake-on-LAN')}
</p> </p>
</div> </div>
</div> </div>
@ -41,11 +43,11 @@ export default function EmptyStateCard({
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} /> <Button size="SM" theme="blank" text={t('Close')} onClick={onCancelWakeOnLanModal} />
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Add New Device" text={t('Add_New_Device')}
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
/> />

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useTranslation } from "react-i18next";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
@ -20,7 +21,7 @@ export default function WakeOnLanModal() {
const close = useClose(); const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null); const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const { t } = useTranslation();
const onCancelWakeOnLanModal = useCallback(() => { const onCancelWakeOnLanModal = useCallback(() => {
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
close(); close();
@ -35,12 +36,12 @@ export default function WakeOnLanModal() {
if ("error" in resp) { if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address"); const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) { if (isInvalid) {
setErrorMessage("Invalid MAC address"); setErrorMessage(t('Invalid_MAC_address'));
} else { } else {
setErrorMessage("Failed to send Magic Packet"); setErrorMessage(t('Failed_to_send_Magic_Packet'));
} }
} else { } else {
notifications.success("Magic Packet sent successfully"); notifications.success(t('Magic_Packet_sent_successfully'));
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
close(); close();
} }
@ -87,7 +88,7 @@ export default function WakeOnLanModal() {
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => { send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error); console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device"); setAddDeviceErrorMessage(t('Failed_to_add_device'));
} else { } else {
setShowAddForm(false); setShowAddForm(false);
syncStoredDevices(); syncStoredDevices();
@ -96,15 +97,14 @@ export default function WakeOnLanModal() {
}, },
[send, storedDevices, syncStoredDevices], [send, storedDevices, syncStoredDevices],
); );
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-(--grid-headerBody)"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Wake On LAN" title={t('Wake_on_LAN')}
description="Send a Magic Packet to wake up a remote device." description={t('Send_a_Magic_Packet_to_wake_up_a_remote_device')}
/> />
{showAddForm ? ( {showAddForm ? (

View File

@ -1,4 +1,5 @@
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import { useTranslation } from "react-i18next";
import SidebarHeader from "@/components/SidebarHeader"; import SidebarHeader from "@/components/SidebarHeader";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
@ -92,10 +93,10 @@ export default function ConnectionStatsSidebar() {
const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000); const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000);
return { date: d.date, metric: valueMs }; return { date: d.date, metric: valueMs };
}); });
const { t } = useTranslation();
return ( return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs"> <div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title={t('Connection_Stats')} 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="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"> <div className="space-y-4">
{sidebarView === "connection-stats" && ( {sidebarView === "connection-stats" && (
@ -103,12 +104,12 @@ export default function ConnectionStatsSidebar() {
{/* Connection Group */} {/* Connection Group */}
<div className="space-y-3"> <div className="space-y-3">
<SettingsSectionHeader <SettingsSectionHeader
title="Connection" title={t('Connection')}
description="The connection between the client and the JetKVM." description={t('The_connection_between_the_client_and_the_JetKVM')}
/> />
<Metric <Metric
title="Round-Trip Time" title={t('Round-Trip_Time')}
description="Round-trip time for the active ICE candidate pair between peers." description={t('Round-trip_time_for_the_active_ICE_candidate_pair_between_peers')}
stream={iceCandidatePairStats} stream={iceCandidatePairStats}
metric="currentRoundTripTime" metric="currentRoundTripTime"
map={x => ({ map={x => ({
@ -123,16 +124,16 @@ export default function ConnectionStatsSidebar() {
{/* Video Group */} {/* Video Group */}
<div className="space-y-3"> <div className="space-y-3">
<SettingsSectionHeader <SettingsSectionHeader
title="Video" title={t('Video')}
description="The video stream from the JetKVM to the client." description={t('The_video_stream_from_the_JetKVM_to_the_client')}
/> />
{/* RTP Jitter */} {/* RTP Jitter */}
<Metric <Metric
title="Network Stability" title={t('Network_Stability')}
badge="Jitter" badge={t('Jitter')}
badgeTheme="light" badgeTheme="light"
description="How steady the flow of inbound video packets is across the network." description={t('How_steady_the_flow_of_inbound_video_packets_is_across_the_network')}
stream={inboundVideoRtpStats} stream={inboundVideoRtpStats}
metric="jitter" metric="jitter"
map={x => ({ map={x => ({
@ -140,14 +141,14 @@ export default function ConnectionStatsSidebar() {
metric: x.metric != null ? Math.round(x.metric * 1000) : null, metric: x.metric != null ? Math.round(x.metric * 1000) : null,
})} })}
domain={[0, 10]} domain={[0, 10]}
unit=" ms" unit={t('ms')}
/> />
{/* Playback Delay */} {/* Playback Delay */}
<Metric <Metric
title="Playback Delay" title={t('Playback_Delay')}
description="Delay added by the jitter buffer to smooth playback when frames arrive unevenly." description={t('Delay_added_by_the_jitter_buffer_to_smooth_playback_when_frames_arrive_unevenly')}
badge="Jitter Buffer Avg. Delay" badge={t('Jitter_Buffer_Avg_Delay')}
badgeTheme="light" badgeTheme="light"
data={jitterBufferAvgDelayData} data={jitterBufferAvgDelayData}
gate={inboundVideoRtpStats} gate={inboundVideoRtpStats}
@ -162,27 +163,27 @@ export default function ConnectionStatsSidebar() {
) )
} }
domain={[0, 30]} domain={[0, 30]}
unit=" ms" unit={t('ms')}
/> />
{/* Packets Lost */} {/* Packets Lost */}
<Metric <Metric
title="Packets Lost" title={t('Packets_Lost')}
description="Count of lost inbound video RTP packets." description={t('Count_of_lost_inbound_video_RTP_packets')}
stream={inboundVideoRtpStats} stream={inboundVideoRtpStats}
metric="packetsLost" metric="packetsLost"
domain={[0, 100]} domain={[0, 100]}
unit=" packets" unit={t('packets')}
/> />
{/* Frames Per Second */} {/* Frames Per Second */}
<Metric <Metric
title="Frames per second" title={t('Frames_per_second')}
description="Number of inbound video frames displayed per second." description={t('Number_of_inbound_video_frames_displayed_per_second')}
stream={inboundVideoRtpStats} stream={inboundVideoRtpStats}
metric="framesPerSecond" metric="framesPerSecond"
domain={[0, 80]} domain={[0, 80]}
unit=" fps" unit={t('fps')}
/> />
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { lazy } from "react"; import { lazy } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "./index.css"; import '@/i18n';
import "@/index.css";
import { import {
createBrowserRouter, createBrowserRouter,
isRouteErrorResponse, isRouteErrorResponse,
@ -9,6 +10,7 @@ import {
useRouteError, useRouteError,
} from "react-router"; } from "react-router";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import { useTranslation } from "react-i18next";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api"; import api from "@/api";
@ -390,7 +392,7 @@ document.addEventListener("DOMContentLoaded", () => {
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
function ErrorBoundary() { function ErrorBoundary() {
const error = useRouteError(); const error = useRouteError();
const { t } = useTranslation();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
const errorMessage = error?.data?.error?.message || error?.message; const errorMessage = error?.data?.error?.message || error?.message;
@ -404,8 +406,8 @@ function ErrorBoundary() {
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<EmptyCard <EmptyCard
IconElm={ExclamationTriangleIcon} IconElm={ExclamationTriangleIcon}
headline="Oh no!" headline={t('Oh_no!')}
description="Something went wrong. Please try again later or contact support" description={t('Something_went_wrong_Please_try_again_later_or_contact_support')}
BtnElm={ BtnElm={
errorMessage && ( errorMessage && (
<Card> <Card>

View File

@ -1,6 +1,7 @@
import { Form, redirect, useActionData, useLoaderData } from "react-router"; import { Form, redirect, useActionData, useLoaderData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router"; import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { ChevronLeftIcon } from "@heroicons/react/16/solid"; import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
@ -15,10 +16,10 @@ interface LoaderData {
device: { id: string; name: string; user: { googleId: string } }; device: { id: string; name: string; user: { googleId: string } };
user: User; user: User;
} }
// eslint-disable-next-line react-hooks/rules-of-hooks
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const { deviceId } = Object.fromEntries(await request.formData()); const { deviceId } = Object.fromEntries(await request.formData());
const { t } = useTranslation();
try { try {
const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, { const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, {
method: "DELETE", method: "DELETE",
@ -28,11 +29,11 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
}); });
if (!res.ok) { if (!res.ok) {
return { message: "There was an error deregistering your device. Please try again." }; return { message: t('There_was_an_error_deregistering_your_device_Please_try_again') };
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return { message: "There was an error deregistering your device. Please try again." }; return { message: t('There_was_an_error_deregistering_your_device_Please_try_again') };
} }
return redirect("/devices"); return redirect("/devices");
@ -63,12 +64,12 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
export default function DevicesIdDeregister() { export default function DevicesIdDeregister() {
const { device, user } = useLoaderData() as LoaderData; const { device, user } = useLoaderData() as LoaderData;
const error = useActionData() as { message: string }; const error = useActionData() as { message: string };
const { t } = useTranslation();
return ( return (
<div className="grid min-h-screen grid-rows-(--grid-layout)"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<DashboardNavbar <DashboardNavbar
isLoggedIn={!!user} isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={[{ title: t('Cloud_Devices'), to: "/devices" }]}
userEmail={user?.email} userEmail={user?.email}
picture={user?.picture} picture={user?.picture}
kvmName={device?.name} kvmName={device?.name}
@ -82,7 +83,7 @@ export default function DevicesIdDeregister() {
size="SM" size="SM"
theme="blank" theme="blank"
LeadingIcon={ChevronLeftIcon} LeadingIcon={ChevronLeftIcon}
text="Back to Devices" text={t('Back_to_Devices')}
to="/devices" to="/devices"
/> />
<Card className="max-w-3xl p-6"> <Card className="max-w-3xl p-6">
@ -91,10 +92,9 @@ export default function DevicesIdDeregister() {
headline={`Deregister ${device.name || device.id} from your cloud account`} headline={`Deregister ${device.name || device.id} from your cloud account`}
description={ description={
<> <>
This will remove the device from your cloud account and revoke {t('This_will_remove_the_device_from_your_cloud_account_and_revoke_remote_access_to_it')}
remote access to it.
<br /> <br />
Please note that local access will still be possible {t('Please_note_that_local_access_will_still_be_possible')})
</> </>
} }
/> />
@ -107,14 +107,14 @@ export default function DevicesIdDeregister() {
size="MD" size="MD"
theme="light" theme="light"
to="/devices" to="/devices"
text="Cancel" text={t('Cancel')}
textAlign="center" textAlign="center"
/> />
<Button <Button
size="MD" size="MD"
theme="danger" theme="danger"
type="submit" type="submit"
text="Deregister from Cloud" text={t('Deregister_from_Cloud')}
textAlign="center" textAlign="center"
/> />
</div> </div>

View File

@ -8,6 +8,7 @@ import {
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid"; import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import Card, { GridCard } from "@/components/Card"; import Card, { GridCard } from "@/components/Card";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -61,6 +62,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
} }
const { send } = useJsonRpc(); const { send } = useJsonRpc();
async function syncRemoteVirtualMediaState() { async function syncRemoteVirtualMediaState() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
send("getVirtualMediaState", {}, (resp: JsonRpcResponse) => { send("getVirtualMediaState", {}, (resp: JsonRpcResponse) => {
@ -233,31 +235,31 @@ function ModeSelectionView({
setSelectedMode: (mode: "url" | "device") => void; setSelectedMode: (mode: "url" | "device") => void;
}) { }) {
const { setModalView } = useMountMediaStore(); const { setModalView } = useMountMediaStore();
const { t } = useTranslation();
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="animate-fadeIn space-y-0 opacity-0"> <div className="animate-fadeIn space-y-0 opacity-0">
<h2 className="text-lg leading-tight font-bold dark:text-white"> <h2 className="text-lg leading-tight font-bold dark:text-white">
Virtual Media Source {t('Virtual_Media_Source')}
</h2> </h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400"> <div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
Choose how you want to mount your virtual media {t('Choose_how_you_want_to_mount_your_virtual_media')}
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{[ {[
{ {
label: "URL Mount", label: t('URL_Mount'),
value: "url", value: "url",
description: "Mount files from any public web address", description: t('Mount_files_from_any_public_web_address'),
icon: LuLink, icon: LuLink,
tag: "Experimental", tag: t('Experimental'),
disabled: false, disabled: false,
}, },
{ {
label: "JetKVM Storage Mount", label: t('JetKVM_Storage_Mount'),
value: "device", value: "device",
description: "Mount previously uploaded files from the JetKVM storage", description: t('Mount_previously_uploaded_files_from_the_JetKVM_storage'),
icon: LuRadioReceiver, icon: LuRadioReceiver,
tag: null, tag: null,
disabled: false, disabled: false,
@ -273,7 +275,7 @@ function ModeSelectionView({
> >
<Card <Card
className={cx( className={cx(
"w-full min-w-[250px] cursor-pointer bg-white shadow-xs transition-all duration-100 hover:shadow-md dark:bg-slate-800", "w-full min-w-[250px] h-[145px] cursor-pointer bg-white shadow-xs transition-all duration-100 hover:shadow-md dark:bg-slate-800",
{ {
"ring-2 ring-blue-700": selectedMode === mode, "ring-2 ring-blue-700": selectedMode === mode,
"hover:ring-2 hover:ring-blue-500": selectedMode !== mode && !disabled, "hover:ring-2 hover:ring-blue-500": selectedMode !== mode && !disabled,
@ -325,14 +327,14 @@ function ModeSelectionView({
}} }}
> >
<div className="flex gap-x-2 pt-2"> <div className="flex gap-x-2 pt-2">
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" /> <Button size="MD" theme="blank" onClick={onClose} text={t('Cancel')} />
<Button <Button
size="MD" size="MD"
theme="primary" theme="primary"
onClick={() => { onClick={() => {
setModalView(selectedMode); setModalView(selectedMode);
}} }}
text="Continue" text={t('Continue')}
/> />
</div> </div>
</div> </div>
@ -351,7 +353,7 @@ function UrlView({
}) { }) {
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM"); const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const [url, setUrl] = useState<string>(""); const [url, setUrl] = useState<string>("");
const { t } = useTranslation();
const popularImages = [ const popularImages = [
{ {
name: "Ubuntu 24.04 LTS", name: "Ubuntu 24.04 LTS",
@ -410,8 +412,8 @@ function UrlView({
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<ViewHeader <ViewHeader
title="Mount from URL" title={t('Mount_from_URL')}
description="Enter an URL to the image file to mount" description={t('Enter_an_URL_to_the_image_file_to_mount')}
/> />
<div <div
@ -423,7 +425,7 @@ function UrlView({
<InputFieldWithLabel <InputFieldWithLabel
placeholder="https://example.com/image.iso" placeholder="https://example.com/image.iso"
type="url" type="url"
label="Image URL" label={t('Image_URL')}
ref={urlRef} ref={urlRef}
value={url} value={url}
onChange={e => handleUrlChange(e.target.value)} onChange={e => handleUrlChange(e.target.value)}
@ -440,12 +442,12 @@ function UrlView({
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} /> <UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset> </Fieldset>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button size="MD" theme="blank" text="Back" onClick={onBack} /> <Button size="MD" theme="blank" text={t('Back')} onClick={onBack} />
<Button <Button
size="MD" size="MD"
theme="primary" theme="primary"
loading={mountInProgress} loading={mountInProgress}
text="Mount URL" text={t('Mount_URL')}
onClick={() => onMount(url, usbMode)} onClick={() => onMount(url, usbMode)}
disabled={ disabled={
mountInProgress || !urlRef.current?.validity.valid || url.length === 0 mountInProgress || !urlRef.current?.validity.valid || url.length === 0
@ -463,7 +465,7 @@ function UrlView({
}} }}
> >
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white"> <h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images {t('Popular_images')}
</h2> </h2>
<Card className="w-full divide-y divide-slate-800/20 dark:divide-slate-300/20"> <Card className="w-full divide-y divide-slate-800/20 dark:divide-slate-300/20">
{popularImages.map((image, index) => ( {popularImages.map((image, index) => (
@ -487,7 +489,7 @@ function UrlView({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Select" text={t('Select')}
onClick={() => handleUrlChange(image.url)} onClick={() => handleUrlChange(image.url)}
/> />
</div> </div>
@ -523,6 +525,7 @@ function DeviceFileView({
const filesPerPage = 5; const filesPerPage = 5;
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
interface StorageSpace { interface StorageSpace {
bytesUsed: number; bytesUsed: number;
@ -553,7 +556,7 @@ function DeviceFileView({
const syncStorage = useCallback(() => { const syncStorage = useCallback(() => {
send("listStorageFiles", {}, (resp: JsonRpcResponse) => { send("listStorageFiles", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Error listing storage files: ${resp.error}`); notifications.error(t('Error_listing_storage_files_msg',{msg:resp.error}));
return; return;
} }
const { files } = resp.result as StorageFiles; const { files } = resp.result as StorageFiles;
@ -568,7 +571,7 @@ function DeviceFileView({
send("getStorageSpace", {}, (resp: JsonRpcResponse) => { send("getStorageSpace", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Error getting storage space: ${resp.error}`); notifications.error(t('Error_getting_storage_space_msg',{msg:resp.error}));
return; return;
} }
@ -597,7 +600,7 @@ function DeviceFileView({
console.log("Deleting file:", file); console.log("Deleting file:", file);
send("deleteStorageFile", { filename: file.name }, (resp: JsonRpcResponse) => { send("deleteStorageFile", { filename: file.name }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Error deleting file: ${resp.error}`); notifications.error(t('Error_deleting_file_msg',{msg:resp.error}));
return; return;
} }
@ -630,8 +633,8 @@ function DeviceFileView({
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<ViewHeader <ViewHeader
title="Mount from JetKVM Storage" title={t('Mount_from_JetKVM_Storage')}
description="Select an image to mount from the JetKVM storage" description={t('Select_an_image_to_mount_from_the_JetKVM_storage')}
/> />
<div <div
className="w-full animate-fadeIn opacity-0" className="w-full animate-fadeIn opacity-0"
@ -647,17 +650,17 @@ function DeviceFileView({
<div className="space-y-1"> <div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" /> <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"> <h3 className="text-sm leading-none font-semibold text-black dark:text-white">
No images available {t('No_images_available')}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Upload an image to start virtual media mounting. {t('Upload_an_image_to_start_virtual_media_mounting')}
</p> </p>
</div> </div>
<div> <div>
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Upload a new image" text={t('Upload_a_new_image')}
onClick={() => onNewImageClick()} onClick={() => onNewImageClick()}
/> />
</div> </div>
@ -678,7 +681,7 @@ function DeviceFileView({
if (!selectedFile) return; if (!selectedFile) return;
if ( if (
window.confirm( window.confirm(
"Are you sure you want to delete " + selectedFile.name + "?", t('Are_you_sure_you_want_to_delete',{file:selectedFile.name}),
) )
) { ) {
handleDeleteFile(selectedFile); handleDeleteFile(selectedFile);
@ -692,24 +695,24 @@ function DeviceFileView({
{onStorageFiles.length > filesPerPage && ( {onStorageFiles.length > filesPerPage && (
<div className="flex items-center justify-between px-3 py-2"> <div className="flex items-center justify-between px-3 py-2">
<p className="text-sm text-slate-700 dark:text-slate-300"> <p className="text-sm text-slate-700 dark:text-slate-300">
Showing <span className="font-bold">{indexOfFirstFile + 1}</span> to{" "} {t('Showing')} <span className="font-bold">{indexOfFirstFile + 1}</span> {t('to')}
<span className="font-bold"> <span className="font-bold">
{Math.min(indexOfLastFile, onStorageFiles.length)} {Math.min(indexOfLastFile, onStorageFiles.length)}
</span>{" "} </span>{" "}
of <span className="font-bold">{onStorageFiles.length}</span> results of <span className="font-bold">{onStorageFiles.length}</span> {t('results')}
</p> </p>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Previous" text={t('Previous')}
onClick={handlePreviousPage} onClick={handlePreviousPage}
disabled={currentPage === 1} disabled={currentPage === 1}
/> />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Next" text={t('Next')}
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
/> />
@ -733,12 +736,12 @@ function DeviceFileView({
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} /> <UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset> </Fieldset>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} /> <Button size="MD" theme="blank" text={t('Back')} onClick={() => onBack()} />
<Button <Button
size="MD" size="MD"
disabled={selected === null || mountInProgress} disabled={selected === null || mountInProgress}
theme="primary" theme="primary"
text="Mount File" text={t('Mount_File')}
loading={mountInProgress} loading={mountInProgress}
onClick={() => onClick={() =>
onMountStorageFile( onMountStorageFile(
@ -758,7 +761,7 @@ function DeviceFileView({
}} }}
> >
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button size="MD" theme="light" text="Back" onClick={() => onBack()} /> <Button size="MD" theme="light" text={t('Back')} onClick={() => onBack()} />
</div> </div>
</div> </div>
)} )}
@ -772,10 +775,10 @@ function DeviceFileView({
> >
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white"> <span className="font-medium text-black dark:text-white">
Available Storage {t('Available_Storage')}
</span> </span>
<span className="text-slate-700 dark:text-slate-300"> <span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used {percentageUsed}% {t('used')}
</span> </span>
</div> </div>
<div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700"> <div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700">
@ -786,10 +789,10 @@ function DeviceFileView({
</div> </div>
<div className="flex justify-between text-sm text-slate-600"> <div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300"> <span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used {formatters.bytes(bytesUsed)} {t('used')}
</span> </span>
<span className="text-slate-700 dark:text-slate-300"> <span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free {formatters.bytes(bytesFree)} {t('free')}
</span> </span>
</div> </div>
</div> </div>
@ -806,7 +809,7 @@ function DeviceFileView({
size="MD" size="MD"
theme="light" theme="light"
fullWidth fullWidth
text="Upload a new image" text={t('Upload_a_new_image')}
onClick={() => onNewImageClick()} onClick={() => onNewImageClick()}
/> />
</div> </div>
@ -835,6 +838,7 @@ function UploadFileView({
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null); const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
useEffect(() => { useEffect(() => {
@ -862,7 +866,7 @@ function UploadFileView({
if (!rtcDataChannel) { if (!rtcDataChannel) {
console.error("Failed to create data channel for file upload"); console.error("Failed to create data channel for file upload");
notifications.error("Failed to create data channel for file upload"); notifications.error(t('Failed_to_create_data_channel_for_file_upload'));
setUploadState("idle"); setUploadState("idle");
console.log("Upload state set to 'idle'"); console.log("Upload state set to 'idle'");
@ -952,7 +956,7 @@ function UploadFileView({
rtcDataChannel.onerror = error => { rtcDataChannel.onerror = error => {
console.error("RTC Data channel error:", error); console.error("RTC Data channel error:", error);
notifications.error(`Upload failed: ${error}`); notifications.error(t('Upload_failed_msg',{msg:error}));
setUploadState("idle"); setUploadState("idle");
console.log("Upload state set to 'idle'"); console.log("Upload state set to 'idle'");
}; };
@ -1080,11 +1084,11 @@ function UploadFileView({
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<ViewHeader <ViewHeader
title="Upload New Image" title={t('Upload_New_Image')}
description={ description={
incompleteFileName incompleteFileName
? `Continue uploading "${incompleteFileName}"` ? t('Continue_uploading_fille',{file:incompleteFileName})
: "Select an image file to upload to JetKVM storage" : t('Select_an_image_file_to_upload_to_JetKVM_storage')
} }
/> />
<div <div
@ -1121,11 +1125,11 @@ function UploadFileView({
</div> </div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white"> <h3 className="text-sm leading-none font-semibold text-black dark:text-white">
{incompleteFileName {incompleteFileName
? `Click to select "${incompleteFileName.replace(".incomplete", "")}"` ? `${t('Click_to_select')} "${incompleteFileName.replace(".incomplete", "")}"`
: "Click to select a file"} : t('Click_to_select_a_file')}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Supported formats: ISO, IMG {t('Supported_formats')}: ISO, IMG
</p> </p>
</div> </div>
)} )}
@ -1140,7 +1144,7 @@ function UploadFileView({
</Card> </Card>
</div> </div>
<h3 className="leading-non text-lg font-semibold text-black dark:text-white"> <h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)} {t('Uploading')} {formatters.truncateMiddle(uploadedFileName, 30)}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.bytes(uploadedFileSize || 0)} {formatters.bytes(uploadedFileSize || 0)}
@ -1153,11 +1157,11 @@ function UploadFileView({
></div> ></div>
</div> </div>
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400"> <div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
<span>Uploading...</span> <span>{t('Uploading...')}</span>
<span> <span>
{uploadSpeed !== null {uploadSpeed !== null
? `${formatters.bytes(uploadSpeed)}/s` ? `${formatters.bytes(uploadSpeed)}/s`
: "Calculating..."} : t('Calculating...')}
</span> </span>
</div> </div>
</div> </div>
@ -1174,11 +1178,10 @@ function UploadFileView({
</Card> </Card>
</div> </div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white"> <h3 className="text-sm leading-none font-semibold text-black dark:text-white">
Upload successful {t('Upload_successful')}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.truncateMiddle(uploadedFileName, 40)} has been {formatters.truncateMiddle(uploadedFileName, 40)} {t('has_been_uploaded')}
uploaded
</p> </p>
</div> </div>
)} )}
@ -1221,7 +1224,7 @@ function UploadFileView({
<Button <Button
size="MD" size="MD"
theme="light" theme="light"
text="Cancel Upload" text={t('Cancel_Upload')}
onClick={() => { onClick={() => {
onCancelUpload(); onCancelUpload();
setUploadState("idle"); setUploadState("idle");
@ -1235,7 +1238,7 @@ function UploadFileView({
<Button <Button
size="MD" size="MD"
theme={uploadState === "success" ? "primary" : "light"} theme={uploadState === "success" ? "primary" : "light"}
text="Back to Overview" text={t('Back_to_Overview')}
onClick={onBack} onClick={onBack}
/> />
)} )}
@ -1254,15 +1257,16 @@ function ErrorView({
onClose: () => void; onClose: () => void;
onRetry: () => void; onRetry: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600"> <div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="h-6 w-6" /> <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">{t('Mount_Error')}</h2>
</div> </div>
<p className="text-sm leading-snug text-slate-600"> <p className="text-sm leading-snug text-slate-600">
An error occurred while attempting to mount the media. Please try again. {t('An_error_occurred_while_attempting_to_mount_the_media_Please_try_again')}
</p> </p>
</div> </div>
{errorMessage && ( {errorMessage && (
@ -1271,8 +1275,8 @@ function ErrorView({
</Card> </Card>
)} )}
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<Button size="SM" theme="light" text="Close" onClick={onClose} /> <Button size="SM" theme="light" text={t('Close')} onClick={onClose} />
<Button size="SM" theme="primary" text="Back to Overview" onClick={onRetry} /> <Button size="SM" theme="primary" text={t('Back_to_Overview')} onClick={onRetry} />
</div> </div>
</div> </div>
); );
@ -1298,6 +1302,7 @@ function PreUploadedImageItem({
onContinueUpload: () => void; onContinueUpload: () => void;
}) { }) {
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const { t } = useTranslation();
return ( return (
<label <label
htmlFor={name} htmlFor={name}
@ -1341,7 +1346,7 @@ function PreUploadedImageItem({
size="XS" size="XS"
theme="light" theme="light"
LeadingIcon={TrashIcon} LeadingIcon={TrashIcon}
text="Delete" text={t('Delete')}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
@ -1362,7 +1367,7 @@ function PreUploadedImageItem({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Continue uploading" text={t('Continue_uploading')}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
onContinueUpload(); onContinueUpload();
@ -1394,9 +1399,10 @@ function UsbModeSelector({
usbMode: RemoteVirtualMediaState["mode"]; usbMode: RemoteVirtualMediaState["mode"];
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void; setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start space-y-1 select-none"> <div className="flex flex-col items-start space-y-1 select-none">
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label> <label className="text-sm font-semibold text-black dark:text-white">{t('Mount_as')}</label>
<div className="flex space-x-4"> <div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center"> <label htmlFor="cdrom" className="flex items-center">
<input <input
@ -1408,7 +1414,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" 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"> <span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
CD/DVD {t('CD/DVD')}
</span> </span>
</label> </label>
<label htmlFor="disk" className="flex items-center"> <label htmlFor="disk" className="flex items-center">
@ -1421,7 +1427,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" 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"> <span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
Disk {t('Disk')}
</span> </span>
</label> </label>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useNavigate, useOutletContext } from "react-router"; import { useNavigate, useOutletContext } from "react-router";
import { useTranslation } from "react-i18next";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -13,7 +14,7 @@ interface ContextType {
export default function OtherSessionRoute() { export default function OtherSessionRoute() {
const outletContext = useOutletContext<ContextType>(); const outletContext = useOutletContext<ContextType>();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
// Function to handle closing the modal // Function to handle closing the modal
const handleClose = () => { const handleClose = () => {
outletContext?.setupPeerConnection().then(() => navigate("..")); outletContext?.setupPeerConnection().then(() => navigate(".."));
@ -30,14 +31,13 @@ export default function OtherSessionRoute() {
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white"> <p className="text-base font-semibold dark:text-white">
Another Active Session Detected {t('Another_Active_Session_Detected')}
</p> </p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> <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 {t('Only_one_active_session_is_supported_at_a_time_Would_you_like_to_take_over_this_session')}
this session?
</p> </p>
<div className="flex items-center justify-start space-x-4"> <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={t('Use_Here')} onClick={handleClose} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,8 @@
import { Form, redirect, useActionData, useLoaderData } from "react-router"; import { Form, redirect, useActionData, useLoaderData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router"; import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { ChevronLeftIcon } from "@heroicons/react/16/solid"; import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
@ -18,13 +20,13 @@ interface LoaderData {
device: { id: string; name: string; user: { googleId: string } }; device: { id: string; name: string; user: { googleId: string } };
user: User; user: User;
} }
// eslint-disable-next-line react-hooks/rules-of-hooks
const action: ActionFunction = async ({ params, request }: ActionFunctionArgs) => { const action: ActionFunction = async ({ params, request }: ActionFunctionArgs) => {
const { id } = params; const { id } = params;
const { name } = Object.fromEntries(await request.formData()); const { name } = Object.fromEntries(await request.formData());
const { t } = useTranslation();
if (!name || name === "") { if (!name || name === "") {
return { message: "Please specify a name" }; return { message: t('Please_specify_a_name') };
} }
try { try {
@ -32,11 +34,11 @@ const action: ActionFunction = async ({ params, request }: ActionFunctionArgs) =
name, name,
}); });
if (!res.ok) { if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." }; return { message: t('There_was_an_error_renaming_your_device_Please_try_again') };
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return { message: "There was an error renaming your device. Please try again." }; return { message: t('There_was_an_error_renaming_your_device_Please_try_again') };
} }
return redirect("/devices"); return redirect("/devices");
@ -67,7 +69,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
export default function DeviceIdRename() { export default function DeviceIdRename() {
const { device, user } = useLoaderData() as LoaderData; const { device, user } = useLoaderData() as LoaderData;
const error = useActionData() as { message: string }; const error = useActionData() as { message: string };
const { t } = useTranslation();
return ( return (
<div className="grid min-h-screen grid-rows-(--grid-layout)"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<DashboardNavbar <DashboardNavbar
@ -86,24 +88,24 @@ export default function DeviceIdRename() {
size="SM" size="SM"
theme="blank" theme="blank"
LeadingIcon={ChevronLeftIcon} LeadingIcon={ChevronLeftIcon}
text="Back to Devices" text={t('Back_to_Devices')}
to="/devices" to="/devices"
/> />
<Card className="max-w-3xl p-6"> <Card className="max-w-3xl p-6">
<div className="space-y-4"> <div className="space-y-4">
<CardHeader <CardHeader
headline={`Rename ${device.name || device.id}`} headline={`Rename ${device.name || device.id}`}
description="Properly name your device to easily identify it." description={t('Properly_name_your_device_to_easily_identify_it')}
/> />
<Fieldset> <Fieldset>
<Form method="POST" className="max-w-sm space-y-4"> <Form method="POST" className="max-w-sm space-y-4">
<div className="group relative"> <div className="group relative">
<InputFieldWithLabel <InputFieldWithLabel
label="New device name" label={t('New_device_name')}
type="text" type="text"
name="name" name="name"
placeholder="Plex Media Server" placeholder={t('Plex_Media_Server')}
size="MD" size="MD"
autoFocus autoFocus
error={error?.message.toString()} error={error?.message.toString()}
@ -114,7 +116,7 @@ export default function DeviceIdRename() {
size="MD" size="MD"
theme="primary" theme="primary"
type="submit" type="submit"
text="Rename Device" text={t('Rename_Device')}
textAlign="center" textAlign="center"
/> />
</Form> </Form>

View File

@ -2,6 +2,7 @@ import { useLoaderData, useNavigate } from "react-router";
import type { LoaderFunction } from "react-router"; import type { LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import api from "@/api"; import api from "@/api";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
@ -44,6 +45,7 @@ export default function SettingsAccessIndexRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [isAdopted, setAdopted] = useState(false); const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null); const [deviceId, setDeviceId] = useState<string | null>(null);
@ -56,6 +58,7 @@ export default function SettingsAccessIndexRoute() {
const [tlsCert, setTlsCert] = useState<string>(""); const [tlsCert, setTlsCert] = useState<string>("");
const [tlsKey, setTlsKey] = useState<string>(""); const [tlsKey, setTlsKey] = useState<string>("");
const getCloudState = useCallback(() => { const getCloudState = useCallback(() => {
send("getCloudState", {}, (resp: JsonRpcResponse) => { send("getCloudState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return console.error(resp.error); if ("error" in resp) return console.error(resp.error);
@ -91,9 +94,7 @@ export default function SettingsAccessIndexRoute() {
const deregisterDevice = () => { const deregisterDevice = () => {
send("deregisterDevice", {}, (resp: JsonRpcResponse) => { send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(t('Failed_to_de-register_device_msg',{ msg:resp.error.data || t('Unknown_error') }));
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return; return;
} }
@ -107,14 +108,14 @@ export default function SettingsAccessIndexRoute() {
const onCloudAdoptClick = useCallback( const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => { (cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) { if (!deviceId) {
notifications.error("No device ID available"); notifications.error(t('No_device_ID_available'));
return; return;
} }
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => { send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`, t('Failed_to_update_cloud_URL_msg',{msg:resp.error.data || t('Unknown_error')}),
); );
return; return;
} }
@ -160,12 +161,12 @@ export default function SettingsAccessIndexRoute() {
send("setTLSState", { state }, (resp: JsonRpcResponse) => { send("setTLSState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update TLS settings: ${resp.error.data || "Unknown error"}`, t('Failed_to_update_TLS_settings_msg',{msg:resp.error.data || t('Unknown_error')}),
); );
return; return;
} }
notifications.success("TLS settings updated successfully"); notifications.success(t('TLS_settings_updated_successfully'));
}); });
}, [send]); }, [send]);
@ -206,22 +207,22 @@ export default function SettingsAccessIndexRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Access" title={t('Access')}
description="Manage the Access Control of the device" description={t('Manage_the_Access_Control_of_the_device')}
/> />
{loaderData?.authMode && ( {loaderData?.authMode && (
<> <>
<div className="space-y-4"> <div className="space-y-4">
<SettingsSectionHeader <SettingsSectionHeader
title="Local" title={t('Local')}
description="Manage the mode of local access to the device" description={t('Manage_the_mode_of_local_access_to_the_device')}
/> />
<> <>
<SettingsItem <SettingsItem
title="HTTPS Mode" title={t('HTTPS_Mode')}
badge="Experimental" badge={t('Experimental')}
description="Configure secure HTTPS access to your device" description={t('Configure_secure_HTTPS_access_to_your_device')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -229,9 +230,9 @@ export default function SettingsAccessIndexRoute() {
onChange={e => handleTlsModeChange(e.target.value)} onChange={e => handleTlsModeChange(e.target.value)}
disabled={tlsMode === "unknown"} disabled={tlsMode === "unknown"}
options={[ options={[
{ value: "disabled", label: "Disabled" }, { value: "disabled", label: t('Disabled') },
{ value: "self-signed", label: "Self-signed" }, { value: "self-signed", label: t('Self-signed') },
{ value: "custom", label: "Custom" }, { value: "custom", label: t('Custom') },
]} ]}
/> />
</SettingsItem> </SettingsItem>
@ -240,8 +241,8 @@ export default function SettingsAccessIndexRoute() {
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="TLS Certificate" title={t('TLS_Certificate')}
description="Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates)." description={t('Paste_your_TLS_certificate_below_For_certificate_chains')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<TextAreaWithLabel <TextAreaWithLabel
@ -258,8 +259,8 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<TextAreaWithLabel <TextAreaWithLabel
label="Private Key" label={t('Private_Key')}
description="For security reasons, it will not be displayed after saving." description={t('For_security_reasons_it_will_not_be_displayed_after_saving')}
rows={3} rows={3}
placeholder={ placeholder={
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
@ -274,7 +275,7 @@ export default function SettingsAccessIndexRoute() {
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Update TLS Settings" text={t('Update_TLS_Settings')}
onClick={handleCustomTlsUpdate} onClick={handleCustomTlsUpdate}
/> />
</div> </div>
@ -282,14 +283,14 @@ export default function SettingsAccessIndexRoute() {
)} )}
<SettingsItem <SettingsItem
title="Authentication Mode" title={t('Authentication_Mode')}
description={`Current mode: ${loaderData.authMode === "password" ? "Password protected" : "No password"}`} description={t('Current_mode_state',{state:loaderData.authMode === "password" ? t('Password_protected') : t('No_password')})}
> >
{loaderData.authMode === "password" ? ( {loaderData.authMode === "password" ? (
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Disable Protection" text={t('Disable_Protection')}
onClick={() => { onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } }); navigateTo("./local-auth", { state: { init: "deletePassword" } });
}} }}
@ -298,7 +299,7 @@ export default function SettingsAccessIndexRoute() {
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Enable Password" text={t('Enable_Password')}
onClick={() => { onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } }); navigateTo("./local-auth", { state: { init: "createPassword" } });
}} }}
@ -309,13 +310,13 @@ export default function SettingsAccessIndexRoute() {
{loaderData.authMode === "password" && ( {loaderData.authMode === "password" && (
<SettingsItem <SettingsItem
title="Change Password" title={t('Change_Password')}
description="Update your device access password" description={t('Update_your_device_access_password')}
> >
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Change Password" text={t('Change_Password')}
onClick={() => { onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } }); navigateTo("./local-auth", { state: { init: "updatePassword" } });
}} }}
@ -329,24 +330,24 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4"> <div className="space-y-4">
<SettingsSectionHeader <SettingsSectionHeader
title="Remote" title={t('Remote')}
description="Manage the mode of Remote access to the device" description={t('Manage_the_mode_of_Remote_access_to_the_device')}
/> />
<div className="space-y-4"> <div className="space-y-4">
{!isAdopted && ( {!isAdopted && (
<> <>
<SettingsItem <SettingsItem
title="Cloud Provider" title={t('Cloud_Provider')}
description="Select the cloud provider for your device" description={t('Select_the_cloud_provider_for_your_device')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={selectedProvider} value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)} onChange={e => handleProviderChange(e.target.value)}
options={[ options={[
{ value: "jetkvm", label: "JetKVM Cloud" }, { value: "jetkvm", label: t('JetKVM_Cloud') },
{ value: "custom", label: "Custom" }, { value: "custom", label: t('Custom') },
]} ]}
/> />
</SettingsItem> </SettingsItem>
@ -356,7 +357,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2"> <div className="flex items-end gap-x-2">
<InputFieldWithLabel <InputFieldWithLabel
size="SM" size="SM"
label="Cloud API URL" label={t('Cloud_API_URL')}
value={cloudApiUrl} value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)} onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com" placeholder="https://api.example.com"
@ -365,7 +366,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2"> <div className="flex items-end gap-x-2">
<InputFieldWithLabel <InputFieldWithLabel
size="SM" size="SM"
label="Cloud App URL" label={t('Cloud_App_URL')}
value={cloudAppUrl} value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)} onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com" placeholder="https://app.example.com"
@ -384,19 +385,19 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security {t('Cloud_Security')}
</h3> </h3>
<div> <div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300"> <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>{t('End-to-end_encryption using_WebRTC_DTLS_and_SRTP')}</li>
<li>Zero Trust security model</li> <li>{t('Zero_Trust_security_model')}</li>
<li>OIDC (OpenID Connect) authentication</li> <li>{t('OIDC_OpenID_Connect_authentication')}</li>
<li>All streams encrypted in transit</li> <li>{t('All_streams_encrypted_in_transit')}</li>
</ul> </ul>
</div> </div>
<div className="text-xs text-slate-700 dark:text-slate-300"> <div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "} {t('All_cloud_components_are_open-source_and_available_on')}
<a <a
href="https://github.com/jetkvm" href="https://github.com/jetkvm"
target="_blank" target="_blank"
@ -415,7 +416,7 @@ export default function SettingsAccessIndexRoute() {
to="https://jetkvm.com/docs/networking/remote-access" to="https://jetkvm.com/docs/networking/remote-access"
size="SM" size="SM"
theme="light" theme="light"
text="Learn about our cloud security" text={t('Learn_about_our_cloud_security')}
/> />
</div> </div>
</div> </div>
@ -429,32 +430,32 @@ export default function SettingsAccessIndexRoute() {
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)} onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM" size="SM"
theme="primary" theme="primary"
text="Adopt KVM to Cloud" text={t('Adopt_KVM_to_Cloud')}
/> />
</div> </div>
) : ( ) : (
<div> <div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to the Cloud {t('Your_device_is_adopted_to_the_Cloud')}
</p> </p>
<div> <div>
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="De-register from Cloud" text={t('De-register_from_Cloud')}
className="text-red-600" className="text-red-600"
onClick={() => { onClick={() => {
if (deviceId) { if (deviceId) {
if ( if (
window.confirm( window.confirm(
"Are you sure you want to de-register this device?", t('Are_you_sure_you_want_to_de-register_this_device'),
) )
) { ) {
deregisterDevice(); deregisterDevice();
} }
} else { } else {
notifications.error("No device ID available"); notifications.error(t('No_device_ID_available'));
} }
}} }}
/> />

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLocation, useRevalidator } from "react-router"; import { useLocation, useRevalidator } from "react-router";
import { useTranslation } from "react-i18next";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField"; import { InputFieldWithLabel } from "@/components/InputField";
@ -28,18 +29,19 @@ export default function SecurityAccessLocalAuthRoute() {
} }
export function Dialog({ onClose }: { onClose: () => void }) { export function Dialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const { modalView, setModalView } = useLocalAuthModalStore(); const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator(); const revalidator = useRevalidator();
const handleCreatePassword = async (password: string, confirmPassword: string) => { const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") { if (password === "") {
setError("Please enter a password"); setError(t('Please_enter_a_password'));
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Passwords do not match"); setError(t('Passwords_do_not_match'));
return; return;
} }
@ -51,11 +53,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate(); revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while setting the password"); setError(data.error || t('An_error_occurred_while_setting_the_password'));
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setError("An error occurred while setting the password"); setError(t('An_error_occurred_while_setting_the_password'));
} }
}; };
@ -65,17 +67,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
confirmNewPassword: string, confirmNewPassword: string,
) => { ) => {
if (newPassword !== confirmNewPassword) { if (newPassword !== confirmNewPassword) {
setError("Passwords do not match"); setError(t('Passwords_do_not_match'));
return; return;
} }
if (oldPassword === "") { if (oldPassword === "") {
setError("Please enter your old password"); setError(t('Please_enter_your_old_password'));
return; return;
} }
if (newPassword === "") { if (newPassword === "") {
setError("Please enter a new password"); setError(t('Please_enter_a_new_password'));
return; return;
} }
@ -91,17 +93,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate(); revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while changing the password"); setError(data.error || t('An_error_occurred_while_changing_the_password'));
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setError("An error occurred while changing the password"); setError(t('An_error_occurred_while_changing_the_password'));
} }
}; };
const handleDeletePassword = async (password: string) => { const handleDeletePassword = async (password: string) => {
if (password === "") { if (password === "") {
setError("Please enter your current password"); setError(t('Please_enter_your_current_password'));
return; return;
} }
@ -113,11 +115,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate(); revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while disabling the password"); setError(data.error || t('An_error_occurred_while_disabling_the_password'));
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setError("An error occurred while disabling the password"); setError(t('An_error_occurred_while_disabling_the_password'));
} }
}; };
@ -150,24 +152,24 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "creationSuccess" && ( {modalView === "creationSuccess" && (
<SuccessModal <SuccessModal
headline="Password Set Successfully" headline={t('Password_Set_Successfully')}
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access." description={t('You_ve_successfully_set_up_local_device_protection_Your_device_is_now_secure_against_unauthorized_local_access')}
onClose={onClose} onClose={onClose}
/> />
)} )}
{modalView === "deleteSuccess" && ( {modalView === "deleteSuccess" && (
<SuccessModal <SuccessModal
headline="Password Protection Disabled" headline={t('Password_Protection_Disabled')}
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure." description={t('You_ve_successfully_disabled_the_password_protection_for_local_access_Remember_your_device_is_now_less_secure')}
onClose={onClose} onClose={onClose}
/> />
)} )}
{modalView === "updateSuccess" && ( {modalView === "updateSuccess" && (
<SuccessModal <SuccessModal
headline="Password Updated Successfully" headline={t('Password_Updated_Successfully')}
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access." description={t('You_ve_successfully_changed_your_local_device_protection_password_Make_sure_to_remember_your_new_password_for_future_access')}
onClose={onClose} onClose={onClose}
/> />
)} )}
@ -185,6 +187,7 @@ function CreatePasswordModal({
onCancel: () => void; onCancel: () => void;
error: string | null; error: string | null;
}) { }) {
const { t } = useTranslation();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@ -198,24 +201,24 @@ function CreatePasswordModal({
> >
<div> <div>
<h2 className="text-lg font-semibold dark:text-white"> <h2 className="text-lg font-semibold dark:text-white">
Local Device Protection {t('Local_Device_Protection')}
</h2> </h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access. {t('Create_a_password_to_protect_your_device_from_unauthorized_local_access')}
</p> </p>
</div> </div>
<InputFieldWithLabel <InputFieldWithLabel
label="New Password" label={t('New_Password')}
type="password" type="password"
placeholder="Enter a strong password" placeholder={t('Enter_a_strong_password')}
value={password} value={password}
autoFocus autoFocus
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
label="Confirm New Password" label={t('Confirm_New_Password')}
type="password" type="password"
placeholder="Re-enter your password" placeholder={t('Re-enter_your_password')}
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
/> />
@ -224,10 +227,10 @@ function CreatePasswordModal({
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Secure Device" text={t('Secure_Device')}
onClick={() => onSetPassword(password, confirmPassword)} onClick={() => onSetPassword(password, confirmPassword)}
/> />
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} /> <Button size="SM" theme="light" text={t('Not_Now')} onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</form> </form>
@ -244,6 +247,7 @@ function DeletePasswordModal({
onCancel: () => void; onCancel: () => void;
error: string | null; error: string | null;
}) { }) {
const { t } = useTranslation();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
return ( return (
@ -251,16 +255,16 @@ function DeletePasswordModal({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold dark:text-white"> <h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection {t('Disable_Local_Device_Protection')}
</h2> </h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection. {t('Enter_your_current_password_to_disable_local_device_protection')}
</p> </p>
</div> </div>
<InputFieldWithLabel <InputFieldWithLabel
label="Current Password" label={t('Current_Password')}
type="password" type="password"
placeholder="Enter your current password" placeholder={t('Please_enter_your_current_password')}
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
/> />
@ -268,10 +272,10 @@ function DeletePasswordModal({
<Button <Button
size="SM" size="SM"
theme="danger" theme="danger"
text="Disable Protection" text={t('Disable_Protection')}
onClick={() => onDeletePassword(password)} onClick={() => onDeletePassword(password)}
/> />
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} /> <Button size="SM" theme="light" text={t('Cancel')} onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</div> </div>
@ -292,6 +296,7 @@ function UpdatePasswordModal({
onCancel: () => void; onCancel: () => void;
error: string | null; error: string | null;
}) { }) {
const { t } = useTranslation();
const [oldPassword, setOldPassword] = useState(""); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState(""); const [confirmNewPassword, setConfirmNewPassword] = useState("");
@ -306,31 +311,30 @@ function UpdatePasswordModal({
> >
<div> <div>
<h2 className="text-lg font-semibold dark:text-white"> <h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password {t('Change_Local_Device_Password')}
</h2> </h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device {t('Enter_your_current_password_and_a_new_password_to_update_your_local_device_protection')}
protection.
</p> </p>
</div> </div>
<InputFieldWithLabel <InputFieldWithLabel
label="Current Password" label={t('Current_Password')}
type="password" type="password"
placeholder="Enter your current password" placeholder={t('Please_enter_your_current_password')}
value={oldPassword} value={oldPassword}
onChange={e => setOldPassword(e.target.value)} onChange={e => setOldPassword(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
label="New Password" label={t('New_Password')}
type="password" type="password"
placeholder="Enter a new strong password" placeholder={t('Enter_a_new_strong_password')}
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
label="Confirm New Password" label={t('Confirm_New_Password')}
type="password" type="password"
placeholder="Re-enter your new password" placeholder={t('Re-enter_your_new_password')}
value={confirmNewPassword} value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)} onChange={e => setConfirmNewPassword(e.target.value)}
/> />
@ -338,10 +342,10 @@ function UpdatePasswordModal({
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Update Password" text={t('Update_Password')}
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)} onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/> />
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} /> <Button size="SM" theme="light" text={t('Cancel')} onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</form> </form>
@ -358,6 +362,7 @@ function SuccessModal({
description: string; description: string;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left"> <div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4"> <div className="space-y-4">
@ -365,7 +370,7 @@ function SuccessModal({
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2> <h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p> <p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div> </div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="primary" text={t('Close')} onClick={onClose} />
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
@ -16,7 +17,7 @@ import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAdvancedRoute() { export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [sshKey, setSSHKey] = useState<string>(""); const [sshKey, setSSHKey] = useState<string>("");
const { setDeveloperMode } = useSettingsStore(); const { setDeveloperMode } = useSettingsStore();
const [devChannel, setDevChannel] = useState(false); const [devChannel, setDevChannel] = useState(false);
@ -66,7 +67,7 @@ export default function SettingsAdvancedRoute() {
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => { send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_USB_emulation_msg',{set:(enabled ? t('enable') : t('disable')),msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -81,11 +82,11 @@ export default function SettingsAdvancedRoute() {
send("resetConfig", {}, (resp: JsonRpcResponse) => { send("resetConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`, t('Failed_to_reset_configuration_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
notifications.success("Configuration reset to default successfully"); notifications.success(t('Configuration_reset_to_default_successfully'));
}); });
}, [send]); }, [send]);
@ -93,11 +94,11 @@ export default function SettingsAdvancedRoute() {
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => { send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`, t('Failed_to_update_SSH_key_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
notifications.success("SSH key updated successfully"); notifications.success(t('SSH_key_updated_successfully'));
}); });
}, [send, sshKey]); }, [send, sshKey]);
@ -106,7 +107,7 @@ export default function SettingsAdvancedRoute() {
send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => { send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_dev_mode_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -121,7 +122,7 @@ export default function SettingsAdvancedRoute() {
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => { send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_dev_channel_state_msg', {msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -136,18 +137,18 @@ export default function SettingsAdvancedRoute() {
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => { send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_loopback-only_mode_msg',{state:enabled ? t('enable') : t('disable'),msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
setLocalLoopbackOnly(enabled); setLocalLoopbackOnly(enabled);
if (enabled) { if (enabled) {
notifications.success( notifications.success(
"Loopback-only mode enabled. Restart your device to apply.", t('Loopback-only_mode_enabled_Restart_your_device_to_apply')
); );
} else { } else {
notifications.success( notifications.success(
"Loopback-only mode disabled. Restart your device to apply.", t('Loopback-only_mode_enabled_Restart_your_device_to_apply'),
); );
} }
}); });
@ -176,14 +177,14 @@ export default function SettingsAdvancedRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Advanced" title={t('Advanced')}
description="Access additional settings for troubleshooting and customization" description={t('Access_additional_settings_for_troubleshooting_and_customization')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Dev Channel Updates" title={t('Dev_Channel_Updates')}
description="Receive early updates from the development channel" description={t('Receive_early_updates_from_the_development_channel')}
> >
<Checkbox <Checkbox
checked={devChannel} checked={devChannel}
@ -193,8 +194,8 @@ export default function SettingsAdvancedRoute() {
/> />
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Developer Mode" title={t('Developer_Mode')}
description="Enable advanced features for developers" description={t('Enable_advanced_features_for_developers')}
> >
<Checkbox <Checkbox
checked={settings.developerMode} checked={settings.developerMode}
@ -220,18 +221,18 @@ export default function SettingsAdvancedRoute() {
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
Developer Mode Enabled {t('Developer_Mode_Enabled')}
</h3> </h3>
<div> <div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300"> <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>{t('Security_is_weakened_while_active')}</li>
<li>Only use if you understand the risks</li> <li>{t('Only_use_if_you_understand_the_risks')}</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="text-xs text-slate-700 dark:text-slate-300"> <div className="text-xs text-slate-700 dark:text-slate-300">
For advanced users only. Not for production use. {t('For_advanced_users_only_Not_for_production_use')}
</div> </div>
</div> </div>
</div> </div>
@ -239,8 +240,8 @@ export default function SettingsAdvancedRoute() {
)} )}
<SettingsItem <SettingsItem
title="Loopback-Only Mode" title={t('Loopback-Only_Mode')}
description="Restrict web interface access to localhost only (127.0.0.1)" description={t('Restrict_web_interface_access_to_localhost_only_127_0_0_1')}
> >
<Checkbox <Checkbox
checked={localLoopbackOnly} checked={localLoopbackOnly}
@ -251,25 +252,25 @@ export default function SettingsAdvancedRoute() {
{isOnDevice && settings.developerMode && ( {isOnDevice && settings.developerMode && (
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="SSH Access" title={t('SSH_Access')}
description="Add your SSH public key to enable secure remote access to the device" description={t('Add_your_SSH_public_key_to_enable_secure_remote_access_to_the_device')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<TextAreaWithLabel <TextAreaWithLabel
label="SSH Public Key" label={t('SSH_Public_Key')}
value={sshKey || ""} value={sshKey || ""}
rows={3} rows={3}
onChange={e => setSSHKey(e.target.value)} onChange={e => setSSHKey(e.target.value)}
placeholder="Enter your SSH public key" placeholder={t('Enter_your_SSH_public_key')}
/> />
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>. {t('The_default_SSH_user_is')} <strong>root</strong>
</p> </p>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Update SSH Key" text={t('Update_SSH_Key')}
onClick={handleUpdateSSHKey} onClick={handleUpdateSSHKey}
/> />
</div> </div>
@ -278,8 +279,8 @@ export default function SettingsAdvancedRoute() {
)} )}
<SettingsItem <SettingsItem
title="Troubleshooting Mode" title={t('Troubleshooting_Mode')}
description="Diagnostic tools and additional controls for troubleshooting and development purposes" description={t('Diagnostic_tools_and_additional_controls_for_troubleshooting_and_development_purposes')}
> >
<Checkbox <Checkbox
defaultChecked={settings.debugMode} defaultChecked={settings.debugMode}
@ -292,27 +293,27 @@ export default function SettingsAdvancedRoute() {
{settings.debugMode && ( {settings.debugMode && (
<> <>
<SettingsItem <SettingsItem
title="USB Emulation" title={t('USB_Emulation')}
description="Control the USB emulation state" description={t('Control_the_USB_emulation_state')}
> >
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text={ text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation" usbEmulationEnabled ? t('Disable_USB_Emulation') : t('Enable_USB_Emulation')
} }
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)} onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/> />
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Reset Configuration" title={t('Reset_Configuration')}
description="Reset configuration to default. This will log you out." description={t('Reset_configuration_to_default_This_will_log_you_out')}
> >
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Reset Config" text={t('Reset_Config')}
onClick={() => { onClick={() => {
handleResetConfig(); handleResetConfig();
window.location.reload(); window.location.reload();
@ -328,22 +329,21 @@ export default function SettingsAdvancedRoute() {
onClose={() => { onClose={() => {
setShowLoopbackWarning(false); setShowLoopbackWarning(false);
}} }}
title="Enable Loopback-Only Mode?" title={t('Enable_Loopback-Only_Mode')}
description={ description={
<> <>
<p> <p>
WARNING: This will restrict web interface access to localhost (127.0.0.1) {t('WARNING_This_will_restrict_web_interface_access_to_localhost_127_0_0_1_only')}
only.
</p> </p>
<p>Before enabling this feature, make sure you have either:</p> <p>{t('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"> <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>{t('SSH_access_configured_and_tested')}</li>
<li>Cloud access enabled and working</li> <li>{t('Cloud_access_enabled_and_working')}</li>
</ul> </ul>
</> </>
} }
variant="warning" variant="warning"
confirmText="I Understand, Enable Anyway" confirmText={t('I_Understand_Enable_Anyway')}
onConfirm={confirmLoopbackModeEnable} onConfirm={confirmLoopbackModeEnable}
/> />
</div> </div>

View File

@ -1,4 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { SettingsPageHeader } from "../components/SettingsPageheader"; import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
@ -6,6 +7,7 @@ import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAppearanceRoute() { export default function SettingsAppearanceRoute() {
const { t } = useTranslation();
const [currentTheme, setCurrentTheme] = useState(() => { const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system"; return localStorage.theme || "system";
}); });
@ -31,18 +33,18 @@ export default function SettingsAppearanceRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Appearance" title={t('Appearance')}
description="Customize the look and feel of your JetKVM interface" description={t('Customize_the_look_and_feel_of_your_JetKVM_interface')}
/> />
<SettingsItem title="Theme" description="Choose your preferred color theme"> <SettingsItem title={t('Theme')} description={t('Choose_your_preferred_color_theme')}>
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
value={currentTheme} value={currentTheme}
options={[ options={[
{ value: "system", label: "System" }, { value: "system", label: t('System_default') },
{ value: "light", label: "Light" }, { value: "light", label: t('Light') },
{ value: "dark", label: "Dark" }, { value: "dark", label: t('Dark') },
]} ]}
onChange={e => { onChange={e => {
setCurrentTheme(e.target.value); setCurrentTheme(e.target.value);

View File

@ -1,5 +1,6 @@
import { useState , useEffect } from "react"; import { useState , useEffect } from "react";
import { useTranslation } from "react-i18next";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
@ -14,6 +15,7 @@ import { SettingsItem } from "./devices.$id.settings";
export default function SettingsGeneralRoute() { export default function SettingsGeneralRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true); const [autoUpdate, setAutoUpdate] = useState(true);
@ -34,7 +36,7 @@ export default function SettingsGeneralRoute() {
send("setAutoUpdateState", { enabled }, (resp: JsonRpcResponse) => { send("setAutoUpdateState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_auto-update_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
@ -45,27 +47,27 @@ export default function SettingsGeneralRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="General" title={t('General')}
description="Configure device settings and update preferences" description={t('Configure_device_settings_and_update_preferences')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2"> <div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem <SettingsItem
title="Check for Updates" title={t('Check_for_Updates')}
description={ description={
currentVersions ? ( currentVersions ? (
<> <>
App: {currentVersions.appVersion} {t('App')}: {currentVersions.appVersion}
<br /> <br />
System: {currentVersions.systemVersion} {t('System')}: {currentVersions.systemVersion}
</> </>
) : ( ) : (
<> <>
App: Loading... {t('App')}: {t('Loading...')}
<br /> <br />
System: Loading... {t('System')}: {t('Loading...')}
</> </>
) )
} }
@ -74,15 +76,15 @@ export default function SettingsGeneralRoute() {
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Check for Updates" text={t('Check_for_Updates')}
onClick={() => navigateTo("./update")} onClick={() => navigateTo("./update")}
/> />
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Auto Update" title={t('Auto_Update')}
description="Automatically update the device to the latest version" description={t('Automatically_update_the_device_to_the_latest_version')}
> >
<Checkbox <Checkbox
checked={autoUpdate} checked={autoUpdate}
@ -95,14 +97,14 @@ export default function SettingsGeneralRoute() {
<div className="mt-2 flex items-center justify-between gap-x-2"> <div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem <SettingsItem
title="Reboot Device" title={t('Reboot_Device')}
description="Power cycle the JetKVM" description={t('Power_cycle_the_JetKVM')}
/> />
<div> <div>
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Reboot Device" text={t('Reboot_Device')}
onClick={() => navigateTo("./reboot")} onClick={() => navigateTo("./reboot")}
/> />
</div> </div>

View File

@ -1,5 +1,6 @@
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -46,19 +47,20 @@ function ConfirmationBox({
onYes: () => void; onYes: () => void;
onNo: () => void; onNo: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Reboot JetKVM {t('重启JetKVM')}
</p> </p>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Do you want to proceed with rebooting the system? {t('Do_you_want_to_proceed_with_rebooting_the_system')}
</p> </p>
<div className="mt-4 flex gap-x-2"> <div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Yes" onClick={onYes} /> <Button size="SM" theme="light" text={t('Yes')} onClick={onYes} />
<Button size="SM" theme="blank" text="No" onClick={onNo} /> <Button size="SM" theme="blank" text={t('No')} onClick={onNo} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card"; import Card from "@/components/Card";
@ -163,16 +164,16 @@ function LoadingState({
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
}; };
}, [getVersionInfo, onFinished]); }, [getVersionInfo, onFinished]);
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Checking for updates... {t('Checking_for_updates')}
</p> </p>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
We{"'"}re ensuring your device has the latest features and improvements. {t('We_re_ensuring_your_device_has_the_latest_features_and_improvements')}
</p> </p>
</div> </div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300">
@ -183,7 +184,7 @@ function LoadingState({
></div> ></div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} /> <Button size="SM" theme="light" text={t('Cancel')} onClick={onCancelCheck} />
</div> </div>
</div> </div>
</div> </div>
@ -197,6 +198,7 @@ function UpdatingDeviceState({
otaState: UpdateState["otaState"]; otaState: UpdateState["otaState"];
onMinimizeUpgradeDialog: () => void; onMinimizeUpgradeDialog: () => void;
}) { }) {
const { t } = useTranslation();
const formatProgress = (progress: number) => `${Math.round(progress)}%`; const formatProgress = (progress: number) => `${Math.round(progress)}%`;
const calculateOverallProgress = (type: "system" | "app") => { const calculateOverallProgress = (type: "system" | "app") => {
@ -238,15 +240,15 @@ function UpdatingDeviceState({
const updatedAt = otaState[`${type}UpdatedAt`]; const updatedAt = otaState[`${type}UpdatedAt`];
if (!otaState.metadataFetchedAt) { if (!otaState.metadataFetchedAt) {
return "Fetching update information..."; return t('Fetching_update_information');
} else if (!downloadFinishedAt) { } else if (!downloadFinishedAt) {
return `Downloading ${type} update...`; return t('Downloading_type_update',{type:t(type)});
} else if (!verfiedAt) { } else if (!verfiedAt) {
return `Verifying ${type} update...`; return t('Verifying_type_update',{type:t(type)});
} else if (!updatedAt) { } else if (!updatedAt) {
return `Installing ${type} update...`; return t('Installing_type_update',{type:t(type)});
} else { } else {
return `Awaiting reboot`; return t('Awaiting_reboot');
} }
}; };
@ -269,10 +271,10 @@ function UpdatingDeviceState({
<div className="w-full max-w-sm space-y-4"> <div className="w-full max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Updating your device {t('Updating_your_device')}
</p> </p>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Please don{"'"}t turn off your device. This process may take a few minutes. {t('Please_dont_turn_off_your_device_This_process_may_take_a_few_minutes')}
</p> </p>
</div> </div>
<Card className="space-y-4 p-4"> <Card className="space-y-4 p-4">
@ -281,7 +283,7 @@ function UpdatingDeviceState({
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300"> <div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white"> <span className="font-medium text-black dark:text-white">
Rebooting to complete the update... {t('Rebooting_to_complete_the_update')}
</span> </span>
</div> </div>
</div> </div>
@ -297,7 +299,7 @@ function UpdatingDeviceState({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white"> <p className="text-sm font-semibold text-black dark:text-white">
Linux System Update {t('Linux_System_Update')}
</p> </p>
{calculateOverallProgress("system") < 100 ? ( {calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
@ -329,7 +331,7 @@ function UpdatingDeviceState({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white"> <p className="text-sm font-semibold text-black dark:text-white">
App Update {t('App_Update')}
</p> </p>
{calculateOverallProgress("app") < 100 ? ( {calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
@ -361,7 +363,7 @@ function UpdatingDeviceState({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Update in Background" text={t('Update_in_Background')}
onClick={onMinimizeUpgradeDialog} onClick={onMinimizeUpgradeDialog}
/> />
</div> </div>
@ -377,19 +379,20 @@ function SystemUpToDateState({
checkUpdate: () => void; checkUpdate: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
System is up to date {t('System_is_up_to_date')}
</p> </p>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Your system is running the latest version. No updates are currently available. {t('Your_system_is_running_the_latest_version_No_updates_are_currently_available')}
</p> </p>
<div className="mt-4 flex gap-x-2"> <div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} /> <Button size="SM" theme="light" text={t('Check_Again')} onClick={checkUpdate} />
<Button size="SM" theme="blank" text="Back" onClick={onClose} /> <Button size="SM" theme="blank" text={t('Back')} onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -405,34 +408,34 @@ function UpdateAvailableState({
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Update available {t('Update_available')}
</p> </p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300"> <p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
A new update is available to enhance system performance and improve {t('A_new_update_is_available_to_enhance_system_performance_and_improve_compatibility_We_recommend_updating_to_ensure_everything_runs_smoothly')}
compatibility. We recommend updating to ensure everything runs smoothly.
</p> </p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemUpdateAvailable ? ( {versionInfo?.systemUpdateAvailable ? (
<> <>
<span className="font-semibold">System:</span>{" "} <span className="font-semibold">{t('System')}:</span>{" "}
{versionInfo?.remote?.systemVersion} {versionInfo?.remote?.systemVersion}
<br /> <br />
</> </>
) : null} ) : null}
{versionInfo?.appUpdateAvailable ? ( {versionInfo?.appUpdateAvailable ? (
<> <>
<span className="font-semibold">App:</span>{" "} <span className="font-semibold">{t('App')}:</span>{" "}
{versionInfo?.remote?.appVersion} {versionInfo?.remote?.appVersion}
</> </>
) : null} ) : null}
</p> </p>
<div className="flex items-center justify-start gap-x-2"> <div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} /> <Button size="SM" theme="primary" text={t('Update_Now')} onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} /> <Button size="SM" theme="light" text={t('Do_it_later')} onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -440,18 +443,18 @@ function UpdateAvailableState({
} }
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white"> <p className="text-base font-semibold dark:text-white">
Update Completed Successfully {t('Update_Completed_Successfully')}
</p> </p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Your device has been successfully updated to the latest version. Enjoy the new {t('Your_device_has_been_successfully_updated_to_the_latest_version_Enjoy_the_new_features_and_improvements')}
features and improvements!
</p> </p>
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Back" onClick={onClose} /> <Button size="SM" theme="primary" text={t('Back')} onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -467,21 +470,22 @@ function UpdateErrorState({
onClose: () => void; onClose: () => void;
onRetryUpdate: () => void; onRetryUpdate: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p> <p className="text-base font-semibold dark:text-white">{t('Update_Error')}</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
An error occurred while updating your device. Please try again later. {t('An_error_occurred_while_updating_your_device_Please_try_again_later')}
</p> </p>
{errorMessage && ( {errorMessage && (
<p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400"> <p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400">
Error details: {errorMessage} {t('Error_details_msg',{msg:errorMessage})}
</p> </p>
)} )}
<div className="flex items-center justify-start gap-x-2"> <div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="light" text="Back" onClick={onClose} /> <Button size="SM" theme="light" text={t('Back')} onClick={onClose} />
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} /> <Button size="SM" theme="blank" text={t('Retry')} onClick={onRetryUpdate} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings"; import { SettingsItem } from "@routes/devices.$id.settings";
@ -13,6 +14,7 @@ import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() { export default function SettingsHardwareRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const settings = useSettingsStore(); const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore(); const { setDisplayRotation } = useSettingsStore();
@ -25,11 +27,11 @@ export default function SettingsHardwareRoute() {
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => { send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_display_orientation_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
notifications.success("Display orientation updated successfully"); notifications.success(t('Display_orientation_updated_successfully'));
}); });
}; };
@ -50,11 +52,11 @@ export default function SettingsHardwareRoute() {
send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => { send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_backlight_settings_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
notifications.success("Backlight settings updated successfully"); notifications.success(t('Backlight_settings_updated_successfully'));
}); });
}; };
@ -62,7 +64,7 @@ export default function SettingsHardwareRoute() {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => { send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
return notifications.error( return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, t('Failed_to_get_backlight_settings_msg',{msg:resp.error.data || t('Unknown_error')})
); );
} }
const result = resp.result as BacklightSettings; const result = resp.result as BacklightSettings;
@ -73,21 +75,21 @@ export default function SettingsHardwareRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Hardware" title={t('Hardware')}
description="Configure display settings and hardware options for your JetKVM device" description={t('Configure_display_settings_and_hardware_options_for_your_JetKVM_device')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Display Orientation" title={t('Display_Orientation')}
description="Set the orientation of the display" description={t('Set_the_orientation_of_the_display')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
value={settings.displayRotation.toString()} value={settings.displayRotation.toString()}
options={[ options={[
{ value: "270", label: "Normal" }, { value: "270", label: t('Normal') },
{ value: "90", label: "Inverted" }, { value: "90", label: t('Inverted') },
]} ]}
onChange={e => { onChange={e => {
settings.displayRotation = e.target.value; settings.displayRotation = e.target.value;
@ -96,18 +98,18 @@ export default function SettingsHardwareRoute() {
/> />
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Display Brightness" title={t('Display_Brightness')}
description="Set the brightness of the display" description={t('Set_the_brightness_of_the_display')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
value={settings.backlightSettings.max_brightness.toString()} value={settings.backlightSettings.max_brightness.toString()}
options={[ options={[
{ value: "0", label: "Off" }, { value: "0", label: t('Off') },
{ value: "10", label: "Low" }, { value: "10", label: t('Low') },
{ value: "35", label: "Medium" }, { value: "35", label: t('Medium') },
{ value: "64", label: "High" }, { value: "64", label: t('High') },
]} ]}
onChange={e => { onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value); settings.backlightSettings.max_brightness = parseInt(e.target.value);
@ -118,20 +120,20 @@ export default function SettingsHardwareRoute() {
{settings.backlightSettings.max_brightness != 0 && ( {settings.backlightSettings.max_brightness != 0 && (
<> <>
<SettingsItem <SettingsItem
title="Dim Display After" title={t('Dim_Display_After')}
description="Set how long to wait before dimming the display" description={t('Set_how_long_to_wait_before_dimming_the_display')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
value={settings.backlightSettings.dim_after.toString()} value={settings.backlightSettings.dim_after.toString()}
options={[ options={[
{ value: "0", label: "Never" }, { value: "0", label: t('Never') },
{ value: "60", label: "1 Minute" }, { value: "60", label: t('num_minute',{num:1}) },
{ value: "300", label: "5 Minutes" }, { value: "300", label: t('num_minute',{num:5}) },
{ value: "600", label: "10 Minutes" }, { value: "600", label: t('num_minute',{num:10}) },
{ value: "1800", label: "30 Minutes" }, { value: "1800", label: t('num_minute',{num:30}) },
{ value: "3600", label: "1 Hour" }, { value: "3600", label: t('1Hour') },
]} ]}
onChange={e => { onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value); settings.backlightSettings.dim_after = parseInt(e.target.value);
@ -140,19 +142,19 @@ export default function SettingsHardwareRoute() {
/> />
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Turn off Display After" title={t('Turn_off_Display_After')}
description="Period of inactivity before display automatically turns off" description={t('Period_of_inactivity_before_display_automatically_turns_off')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
value={settings.backlightSettings.off_after.toString()} value={settings.backlightSettings.off_after.toString()}
options={[ options={[
{ value: "0", label: "Never" }, { value: "0", label: t('Never') },
{ value: "300", label: "5 Minutes" }, { value: "300", label: t('num_minute',{num:5}) },
{ value: "600", label: "10 Minutes" }, { value: "600", label: t('num_minute',{num:10}) },
{ value: "1800", label: "30 Minutes" }, { value: "1800", label: t('num_minute',{num:30}) },
{ value: "3600", label: "1 Hour" }, { value: "3600", label: t('1Hour') },
]} ]}
onChange={e => { onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value); settings.backlightSettings.off_after = parseInt(e.target.value);
@ -163,7 +165,7 @@ export default function SettingsHardwareRoute() {
</> </>
)} )}
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched. {t('The_display_will_wake_up_when_the_connection_state_changes_or_when_touched')}
</p> </p>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
@ -16,6 +17,7 @@ export default function SettingsKeyboardRoute() {
const { selectedKeyboard, keyboardOptions } = useKeyboardLayout(); const { selectedKeyboard, keyboardOptions } = useKeyboardLayout();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
@ -34,10 +36,10 @@ export default function SettingsKeyboardRoute() {
send("setKeyboardLayout", { layout: isoCode }, resp => { send("setKeyboardLayout", { layout: isoCode }, resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_keyboard_layout_msg',{msg:resp.error.data || t('Unknown_error')})
); );
} }
notifications.success("Keyboard layout set successfully to " + isoCode); notifications.success(t('Keyboard_layout_set_successfully_to_code',{code:isoCode}));
setKeyboardLayout(isoCode); setKeyboardLayout(isoCode);
}); });
}, },
@ -47,14 +49,14 @@ export default function SettingsKeyboardRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Keyboard" title={t('Keyboard')}
description="Configure keyboard settings for your device" description={t('Configure_keyboard_settings_for_your_device')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Keyboard Layout" title={t('Keyboard_Layout')}
description="Keyboard layout of target operating system" description={t('Keyboard_layout_of_target_operating_system')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -66,14 +68,14 @@ export default function SettingsKeyboardRoute() {
/> />
</SettingsItem> </SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system. {t('keyboard_layout_notice')}
</p> </p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Show Pressed Keys" title={t('Show_Pressed_Keys')}
description="Display currently pressed keys in the status bar" description={t('Display_currently_pressed_keys_in_the_status_bar')}
> >
<Checkbox <Checkbox
checked={showPressedKeys} checked={showPressedKeys}

View File

@ -1,5 +1,6 @@
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
@ -8,6 +9,7 @@ import { DEFAULT_DELAY } from "@/constants/macros";
import notifications from "@/notifications"; import notifications from "@/notifications";
export default function SettingsMacrosAddRoute() { export default function SettingsMacrosAddRoute() {
const { t } = useTranslation();
const { macros, saveMacros } = useMacrosStore(); const { macros, saveMacros } = useMacrosStore();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@ -30,13 +32,13 @@ export default function SettingsMacrosAddRoute() {
}; };
await saveMacros(normalizeSortOrders([...macros, newMacro])); await saveMacros(normalizeSortOrders([...macros, newMacro]));
notifications.success(`Macro "${newMacro.name}" created successfully`); notifications.success(t('Macro_name_created_successfully',{name:newMacro.name}));
navigate("../"); navigate("../");
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
notifications.error(`Failed to create macro: ${error.message}`); notifications.error(t('Failed_to_create_macro_msg',{msg:error.message}));
} else { } else {
notifications.error("Failed to create macro"); notifications.error(t('Failed_to_create_macro'));
} }
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@ -46,8 +48,8 @@ export default function SettingsMacrosAddRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Add New Macro" title={t('Add_New_Macro')}
description="Create a new keyboard macro" description={t('Create_a_new_keyboard_macro')}
/> />
<MacroForm <MacroForm
initialData={{ initialData={{

View File

@ -1,6 +1,7 @@
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LuTrash2 } from "react-icons/lu"; import { LuTrash2 } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { KeySequence, useMacrosStore } from "@/hooks/stores"; import { KeySequence, useMacrosStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
@ -17,6 +18,7 @@ const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
}; };
export default function SettingsMacrosEditRoute() { export default function SettingsMacrosEditRoute() {
const { t } = useTranslation();
const { macros, saveMacros } = useMacrosStore(); const { macros, saveMacros } = useMacrosStore();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@ -56,13 +58,13 @@ export default function SettingsMacrosEditRoute() {
); );
await saveMacros(normalizeSortOrders(newMacros)); await saveMacros(normalizeSortOrders(newMacros));
notifications.success(`Macro "${updatedMacro.name}" updated successfully`); notifications.success(t('Macro_name_updated_successfully',{name:updatedMacro.name}));
navigate("../"); navigate("../");
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
notifications.error(`Failed to update macro: ${error.message}`); notifications.error(t('Failed_to_update_macro_msg',{msg:error.message}));
} else { } else {
notifications.error("Failed to update macro"); notifications.error(t('Failed_to_update_macro'));
} }
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
@ -76,13 +78,13 @@ export default function SettingsMacrosEditRoute() {
try { try {
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id)); const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
await saveMacros(updatedMacros); await saveMacros(updatedMacros);
notifications.success(`Macro "${macro.name}" deleted successfully`); notifications.success(t('Macro_name_deleted_successfully',{name:macro.name}));
navigate("../macros"); navigate("../macros");
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`); notifications.error(t('Failed_to_delete_macro_msg',{msg:error.message}));
} else { } else {
notifications.error("Failed to delete macro"); notifications.error(t('Failed_to_delete_macro'));
} }
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
@ -95,13 +97,13 @@ export default function SettingsMacrosEditRoute() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<SettingsPageHeader <SettingsPageHeader
title="Edit Macro" title={t('Edit_Macro')}
description="Modify your keyboard macro" description={t('Modify_your_keyboard_macro')}
/> />
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Delete Macro" text={t('Delete_Macro')}
className="text-red-500 dark:text-red-400" className="text-red-500 dark:text-red-400"
LeadingIcon={LuTrash2} LeadingIcon={LuTrash2}
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
@ -113,16 +115,16 @@ export default function SettingsMacrosEditRoute() {
onSubmit={handleUpdateMacro} onSubmit={handleUpdateMacro}
onCancel={() => navigate("../")} onCancel={() => navigate("../")}
isSubmitting={isUpdating} isSubmitting={isUpdating}
submitText="Save Changes" submitText={t('Save_Changes')}
/> />
<ConfirmDialog <ConfirmDialog
open={showDeleteConfirm} open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)} onClose={() => setShowDeleteConfirm(false)}
title="Delete Macro" title={t('Delete_Macro')}
description="Are you sure you want to delete this macro? This action cannot be undone." description={t('Are_you_sure_you_want_to_delete_this_macro_This_action_cannot_be_undone')}
variant="danger" variant="danger"
confirmText={isDeleting ? "Deleting" : "Delete"} confirmText={isDeleting ? t('Deleting') : t('Delete')}
onConfirm={() => { onConfirm={() => {
handleDeleteMacro(); handleDeleteMacro();
setShowDeleteConfirm(false); setShowDeleteConfirm(false);

View File

@ -21,6 +21,7 @@ import notifications from "@/notifications";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import {useTranslation} from "react-i18next";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({ return macros.map((macro, index) => ({
@ -36,7 +37,7 @@ export default function SettingsMacrosRoute() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null); const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
const { selectedKeyboard } = useKeyboardLayout(); const { selectedKeyboard } = useKeyboardLayout();
const { t } = useTranslation();
const isMaxMacrosReached = useMemo( const isMaxMacrosReached = useMemo(
() => macros.length >= MAX_TOTAL_MACROS, () => macros.length >= MAX_TOTAL_MACROS,
[macros.length], [macros.length],
@ -51,12 +52,12 @@ export default function SettingsMacrosRoute() {
const handleDuplicateMacro = useCallback( const handleDuplicateMacro = useCallback(
async (macro: KeySequence) => { async (macro: KeySequence) => {
if (!macro?.id || !macro?.name) { if (!macro?.id || !macro?.name) {
notifications.error("Invalid macro data"); notifications.error(t('Invalid_macro_data'));
return; return;
} }
if (isMaxMacrosReached) { if (isMaxMacrosReached) {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); notifications.error(t('Maximum_of_max_macros_allowed',{max:MAX_TOTAL_MACROS}));
return; return;
} }
@ -71,12 +72,12 @@ export default function SettingsMacrosRoute() {
try { try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); notifications.success(t('Macro_name_duplicated_successfully',{name:newMacroCopy.name}));
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
notifications.error(`Failed to duplicate macro: ${error.message}`); notifications.error(t('Failed_to_duplicate_macro_msg',{msg:error.message}));
} else { } else {
notifications.error("Failed to duplicate macro"); notifications.error(t('Failed_to_duplicate_macro'));
} }
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
@ -88,7 +89,7 @@ export default function SettingsMacrosRoute() {
const handleMoveMacro = useCallback( const handleMoveMacro = useCallback(
async (index: number, direction: "up" | "down", macroId: string) => { async (index: number, direction: "up" | "down", macroId: string) => {
if (!Array.isArray(macros) || macros.length === 0) { if (!Array.isArray(macros) || macros.length === 0) {
notifications.error("No macros available"); notifications.error(t('No_macros_available'));
return; return;
} }
@ -103,12 +104,12 @@ export default function SettingsMacrosRoute() {
const updatedMacros = normalizeSortOrders(newMacros); const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros); await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully"); notifications.success(t('Macro_order_updated_successfully'));
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`); notifications.error(t('Failed_to_reorder_macros_msg',{msg:error.message}));
} else { } else {
notifications.error("Failed to reorder macros"); notifications.error(t('Failed_to_reorder_macros'));
} }
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
@ -126,14 +127,14 @@ export default function SettingsMacrosRoute() {
macros.filter(m => m.id !== macroToDelete.id), macros.filter(m => m.id !== macroToDelete.id),
); );
await saveMacros(updatedMacros); await saveMacros(updatedMacros);
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`); notifications.success(t('Macro_name_deleted_successfully',{name:macroToDelete.name}));
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setMacroToDelete(null); setMacroToDelete(null);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`); notifications.error(t('Failed_to_delete_macro_msg',{msg:error.message}));
} else { } else {
notifications.error("Failed to delete macro"); notifications.error(t('Failed_to_delete_macro'));
} }
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
@ -153,7 +154,7 @@ export default function SettingsMacrosRoute() {
onClick={() => handleMoveMacro(index, "up", macro.id)} onClick={() => handleMoveMacro(index, "up", macro.id)}
disabled={index === 0 || actionLoadingId === macro.id} disabled={index === 0 || actionLoadingId === macro.id}
LeadingIcon={LuArrowUp} LeadingIcon={LuArrowUp}
aria-label={`Move ${macro.name} up`} aria-label={t('Move_name_up',{name:macro.name})}
/> />
<Button <Button
size="XS" size="XS"
@ -161,7 +162,7 @@ export default function SettingsMacrosRoute() {
onClick={() => handleMoveMacro(index, "down", macro.id)} onClick={() => handleMoveMacro(index, "down", macro.id)}
disabled={index === macros.length - 1 || actionLoadingId === macro.id} disabled={index === macros.length - 1 || actionLoadingId === macro.id}
LeadingIcon={LuArrowDown} LeadingIcon={LuArrowDown}
aria-label={`Move ${macro.name} down`} aria-label={t('Move_name_down',{name:macro.name})}
/> />
</div> </div>
@ -251,7 +252,7 @@ export default function SettingsMacrosRoute() {
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
}} }}
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Delete macro ${macro.name}`} aria-label={t('Delete_macro_name',{name:macro.name})}
/> />
<Button <Button
size="XS" size="XS"
@ -259,16 +260,16 @@ export default function SettingsMacrosRoute() {
LeadingIcon={LuCopy} LeadingIcon={LuCopy}
onClick={() => handleDuplicateMacro(macro)} onClick={() => handleDuplicateMacro(macro)}
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`} aria-label={t('Duplicate_macro_name',{name:macro.name})}
/> />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
LeadingIcon={LuPenLine} LeadingIcon={LuPenLine}
text="Edit" text={t('Edit')}
onClick={() => navigate(`${macro.id}/edit`)} onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`} aria-label={t('Edit_macro_name',{name:macro.name})}
/> />
</div> </div>
</div> </div>
@ -281,10 +282,10 @@ export default function SettingsMacrosRoute() {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setMacroToDelete(null); setMacroToDelete(null);
}} }}
title="Delete Macro" title={t('Delete_Macro')}
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`} description={t('Are_you_sure_you_want_to_delete_name_This_action_cannot_be_undone',{name:macroToDelete?.name})}
variant="danger" variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"} confirmText={actionLoadingId === macroToDelete?.id ? t('Deleting...') : t('Delete')}
onConfirm={handleDeleteMacro} onConfirm={handleDeleteMacro}
isConfirming={actionLoadingId === macroToDelete?.id} isConfirming={actionLoadingId === macroToDelete?.id}
/> />
@ -309,18 +310,18 @@ export default function SettingsMacrosRoute() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<SettingsPageHeader <SettingsPageHeader
title="Keyboard Macros" title={t('Keyboard_Macros')}
description={`Combine keystrokes into a single action for faster workflows.`} description={t('Combine_keystrokes_into_a_single_action_for_faster_workflows')}
/> />
{macros.length > 0 && ( {macros.length > 0 && (
<div className="flex items-center pl-2"> <div className="flex items-center pl-2">
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"} text={isMaxMacrosReached ? t('Max_Reached') : t('Add_New_Macro')}
onClick={() => navigate("add")} onClick={() => navigate("add")}
disabled={isMaxMacrosReached} disabled={isMaxMacrosReached}
aria-label="Add new macro" aria-label={t('Add_New_Macro')}
/> />
</div> </div>
)} )}
@ -330,7 +331,7 @@ export default function SettingsMacrosRoute() {
{loading && macros.length === 0 ? ( {loading && macros.length === 0 ? (
<EmptyCard <EmptyCard
IconElm={LuCommand} IconElm={LuCommand}
headline="Loading macros..." headline={t("Loading macros...")}
BtnElm={ BtnElm={
<div className="my-2 flex flex-col items-center space-y-2 text-center"> <div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
@ -340,16 +341,16 @@ export default function SettingsMacrosRoute() {
) : macros.length === 0 ? ( ) : macros.length === 0 ? (
<EmptyCard <EmptyCard
IconElm={LuCommand} IconElm={LuCommand}
headline="Create Your First Macro" headline={t('Create_Your_First_Macro')}
description="Combine keystrokes into a single action" description={t('Combine_keystrokes_into_a_single_action')}
BtnElm={ BtnElm={
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Add New Macro" text={t('Add_New_Macro')}
onClick={() => navigate("add")} onClick={() => navigate("add")}
disabled={isMaxMacrosReached} disabled={isMaxMacrosReached}
aria-label="Add new macro" aria-label={t('Add_New_Macro')}
/> />
} }
/> />

View File

@ -1,5 +1,6 @@
import { CheckCircleIcon } from "@heroicons/react/16/solid"; import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import MouseIcon from "@/assets/mouse-icon.svg"; import MouseIcon from "@/assets/mouse-icon.svg";
import PointingFinger from "@/assets/pointing-finger.svg"; import PointingFinger from "@/assets/pointing-finger.svg";
@ -24,46 +25,45 @@ export interface JigglerConfig {
timezone?: string; timezone?: string;
} }
const jigglerOptions = [
{ value: "disabled", label: "Disabled", config: null },
{
value: "frequent",
label: "Frequent - 30s",
config: {
inactivity_limit_seconds: 30,
jitter_percentage: 25,
schedule_cron_tab: "*/30 * * * * *",
// We don't care about the timezone for this preset
// timezone: "UTC",
},
},
{
value: "standard",
label: "Standard - 1m",
config: {
inactivity_limit_seconds: 60,
jitter_percentage: 25,
schedule_cron_tab: "0 * * * * *",
// We don't care about the timezone for this preset
// timezone: "UTC",
},
},
{
value: "light",
label: "Light - 5m",
config: {
inactivity_limit_seconds: 300,
jitter_percentage: 25,
schedule_cron_tab: "0 */5 * * * *",
// We don't care about the timezone for this preset
// timezone: "UTC",
},
},
] as const;
type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom";
export default function SettingsMouseRoute() { export default function SettingsMouseRoute() {
const { t } = useTranslation();
const jigglerOptions = [
{ value: "disabled", label: t('Disabled'), config: null },
{
value: "frequent",
label: t('Frequent_30s'),
config: {
inactivity_limit_seconds: 30,
jitter_percentage: 25,
schedule_cron_tab: "*/30 * * * * *",
// We don't care about the timezone for this preset
// timezone: "UTC",
},
},
{
value: "standard",
label: t('Standard_1m'),
config: {
inactivity_limit_seconds: 60,
jitter_percentage: 25,
schedule_cron_tab: "0 * * * * *",
// We don't care about the timezone for this preset
// timezone: "UTC",
},
},
{
value: "light",
label: t('Light_5m'),
config: {
inactivity_limit_seconds: 300,
jitter_percentage: 25,
schedule_cron_tab: "0 */5 * * * *",
// We don't care about the timezone for this preset
// timezone: "UTC",
},
},
] as const;
type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom";
const { const {
isCursorHidden, setCursorVisibility, isCursorHidden, setCursorVisibility,
mouseMode, setMouseMode, mouseMode, setMouseMode,
@ -77,11 +77,11 @@ export default function SettingsMouseRoute() {
); );
const scrollThrottlingOptions = [ const scrollThrottlingOptions = [
{ value: "0", label: "Off" }, { value: "0", label: t('Off') },
{ value: "10", label: "Low" }, { value: "10", label: t('Low') },
{ value: "25", label: "Medium" }, { value: "25", label: t('Medium') },
{ value: "50", label: "High" }, { value: "50", label: t('High') },
{ value: "100", label: "Very High" }, { value: "100", label: t('Very_High') },
]; ];
const { send } = useJsonRpc(); const { send } = useJsonRpc();
@ -122,7 +122,7 @@ export default function SettingsMouseRoute() {
send("setJigglerState", { enabled: true }, (resp: JsonRpcResponse) => { send("setJigglerState", { enabled: true }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
return notifications.error( return notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_jiggler_state_msg',{msg:resp.error.data || "Unknown error"})
); );
} }
}); });
@ -138,11 +138,11 @@ export default function SettingsMouseRoute() {
errorMsg.includes("invalid cron") errorMsg.includes("invalid cron")
) { ) {
return notifications.error( return notifications.error(
"Invalid cron expression. Please check your schedule format (e.g., '0 * * * * *' for every minute).", t('Invalid_cron_expression_error')
); );
} }
return notifications.error(`Failed to set jiggler config: ${errorMsg}`); return notifications.error(t('Failed_to_set_jiggler_config_msg',{msg:errorMsg}));
} }
notifications.success(`Jiggler Config successfully updated`); notifications.success(`Jiggler Config successfully updated`);
@ -164,18 +164,18 @@ export default function SettingsMouseRoute() {
send("setJigglerState", { enabled: false }, (resp: JsonRpcResponse) => { send("setJigglerState", { enabled: false }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
return notifications.error( return notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_jiggler_state_msg',{msg:resp.error.data || "Unknown error"})
); );
} }
}); });
notifications.success(`Jiggler Config successfully updated`); notifications.success(t('Jiggler_Config_successfully_updated'));
return setSelectedJigglerOption("disabled"); return setSelectedJigglerOption("disabled");
} }
const jigglerConfig = jigglerOptions.find(o => o.value === option)?.config; const jigglerConfig = jigglerOptions.find(o => o.value === option)?.config;
if (!jigglerConfig) { if (!jigglerConfig) {
return notifications.error("There was an error setting the jiggler config"); return notifications.error(t('There_was_an_error_setting_the_jiggler_config'));
} }
saveJigglerConfig(jigglerConfig); saveJigglerConfig(jigglerConfig);
@ -184,14 +184,14 @@ export default function SettingsMouseRoute() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Mouse" title={t('Mouse')}
description="Configure cursor behavior and interaction settings for your device" description={t('Configure_cursor_behavior_and_interaction_settings_for_your_device')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Hide Cursor" title={t('Hide_Cursor')}
description="Hide the cursor when sending mouse movements" description={t('Hide_the_cursor_when_sending_mouse_movements')}
> >
<Checkbox <Checkbox
checked={isCursorHidden} checked={isCursorHidden}
@ -200,8 +200,8 @@ export default function SettingsMouseRoute() {
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Scroll Throttling" title={t('Scroll_Throttling')}
description="Reduce the frequency of scroll events" description={t('Reduce_the_frequency_of_scroll_events')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -214,7 +214,7 @@ export default function SettingsMouseRoute() {
/> />
</SettingsItem> </SettingsItem>
<SettingsItem title="Jiggler" description="Simulate movement of a computer mouse"> <SettingsItem title={t('Jiggler')} description={t('Simulate_movement_of_a_computer_mouse')}>
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
@ -224,7 +224,7 @@ export default function SettingsMouseRoute() {
value: option.value, value: option.value,
label: option.label, label: option.label,
})), })),
{ value: "custom", label: "Custom" }, { value: "custom", label: t('Custom') },
]} ]}
onChange={e => { onChange={e => {
handleJigglerChange( handleJigglerChange(
@ -243,7 +243,7 @@ export default function SettingsMouseRoute() {
</SettingsNestedSection> </SettingsNestedSection>
)} )}
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" /> <SettingsItem title={t('Modes')} description={t('Choose_the_mouse_input_mode')} />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="group block grow" className="group block grow"
@ -256,15 +256,15 @@ export default function SettingsMouseRoute() {
<img <img
className="w-6 shrink-0 dark:invert" className="w-6 shrink-0 dark:invert"
src={PointingFinger} src={PointingFinger}
alt="Finger touching a screen" alt={t('Finger_touching_a_screen')}
/> />
<div className="flex grow items-center justify-between"> <div className="flex grow items-center justify-between">
<div className="text-left"> <div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white"> <h3 className="text-sm font-semibold text-black dark:text-white">
Absolute {t('Absolute')}
</h3> </h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300"> <p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient {t('Most_convenient')}
</p> </p>
</div> </div>
<CheckCircleIcon <CheckCircleIcon
@ -288,15 +288,15 @@ export default function SettingsMouseRoute() {
<img <img
className="w-6 shrink-0 dark:invert" className="w-6 shrink-0 dark:invert"
src={MouseIcon} src={MouseIcon}
alt="Mouse icon" alt={t('Mouse_icon')}
/> />
<div className="flex grow items-center justify-between"> <div className="flex grow items-center justify-between">
<div className="text-left"> <div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white"> <h3 className="text-sm font-semibold text-black dark:text-white">
Relative {t('Relative')}
</h3> </h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300"> <p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible {t('Most_Compatible')}
</p> </p>
</div> </div>
<CheckCircleIcon <CheckCircleIcon

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { LuEthernetPort } from "react-icons/lu"; import { LuEthernetPort } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { import {
IPv4Mode, IPv4Mode,
@ -45,6 +46,7 @@ const defaultNetworkSettings: NetworkSettings = {
}; };
export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
const { t } = useTranslation();
const [remaining, setRemaining] = useState<string | null>(null); const [remaining, setRemaining] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -57,7 +59,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
}, [lifetime]); }, [lifetime]);
if (lifetime == "") { if (lifetime == "") {
return <strong>N/A</strong>; return <strong>{t('N/A')}</strong>;
} }
return ( return (
@ -73,6 +75,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
export default function SettingsNetworkRoute() { export default function SettingsNetworkRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [networkState, setNetworkState] = useNetworkStateStore(state => [ const [networkState, setNetworkState] = useNetworkStateStore(state => [
state, state,
state.setNetworkState, state.setNetworkState,
@ -132,8 +135,7 @@ export default function SettingsNetworkRoute() {
send("setNetworkSettings", { settings }, (resp: JsonRpcResponse) => { send("setNetworkSettings", { settings }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
"Failed to save network settings: " + t('Failed_to_save_network_settings_msg',{msg:(resp.error.data ? resp.error.data : resp.error.message)})
(resp.error.data ? resp.error.data : resp.error.message),
); );
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
return; return;
@ -144,7 +146,7 @@ export default function SettingsNetworkRoute() {
setNetworkSettings(networkSettings); setNetworkSettings(networkSettings);
getNetworkState(); getNetworkState();
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
notifications.success("Network settings saved"); notifications.success(t('Network_settings_saved'));
}); });
}, },
[getNetworkState, send], [getNetworkState, send],
@ -153,9 +155,9 @@ export default function SettingsNetworkRoute() {
const handleRenewLease = useCallback(() => { const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, (resp: JsonRpcResponse) => { send("renewDHCPLease", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error("Failed to renew lease: " + resp.error.message); notifications.error(t('Failed_to_renew_lease_msg',{msg:resp.error.message}));
} else { } else {
notifications.success("DHCP lease renewed"); notifications.success(t('DHCP_lease_renewed'));
} }
}); });
}, [send]); }, [send]);
@ -223,13 +225,13 @@ export default function SettingsNetworkRoute() {
<> <>
<Fieldset disabled={!networkSettingsLoaded} className="space-y-4"> <Fieldset disabled={!networkSettingsLoaded} className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Network" title={t('Network')}
description="Configure your network settings" description={t('Configure_your_network_settings')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="MAC Address" title={t('MAC_Address')}
description="Hardware identifier for the network interface" description={t('Hardware_identifier_for_the_network_interface')}
> >
<InputField <InputField
type="text" type="text"
@ -243,8 +245,8 @@ export default function SettingsNetworkRoute() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Hostname" title={t('Hostname')}
description="Device identifier on the network. Blank for system default" description={t('Device_identifier_on_the_network_Blank_for_system_default')}
> >
<div className="relative"> <div className="relative">
<div> <div>
@ -263,8 +265,8 @@ export default function SettingsNetworkRoute() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="HTTP Proxy" title={t('HTTP_Proxy')}
description="Proxy server for outgoing HTTP(S) requests from the device. Blank for none." description={t('Proxy_server_for_outgoing_HTTP_S_requests_from_the_device_Blank_for_none')}
> >
<div className="relative"> <div className="relative">
<div> <div>
@ -285,8 +287,8 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<SettingsItem <SettingsItem
title="Domain" title={t('Domain')}
description="Network domain suffix for the device" description={t('Network_domain_suffix_for_the_device')}
> >
<div className="space-y-2"> <div className="space-y-2">
<SelectMenuBasic <SelectMenuBasic
@ -294,9 +296,9 @@ export default function SettingsNetworkRoute() {
value={selectedDomainOption} value={selectedDomainOption}
onChange={e => handleDomainOptionChange(e.target.value)} onChange={e => handleDomainOptionChange(e.target.value)}
options={[ options={[
{ value: "dhcp", label: "DHCP provided" }, { value: "dhcp", label: t('DHCP_provided') },
{ value: "local", label: ".local" }, { value: "local", label: ".local" },
{ value: "custom", label: "Custom" }, { value: "custom", label: t('Custom') },
]} ]}
/> />
</div> </div>
@ -306,7 +308,7 @@ export default function SettingsNetworkRoute() {
<InputFieldWithLabel <InputFieldWithLabel
size="SM" size="SM"
type="text" type="text"
label="Custom Domain" label={t('Custom_Domain')}
placeholder="home" placeholder="home"
value={customDomain} value={customDomain}
onChange={e => { onChange={e => {
@ -320,17 +322,17 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="mDNS" title="mDNS"
description="Control mDNS (multicast DNS) operational mode" description={t('Control_mDNS_multicast_DNS_operational_mode')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.mdns_mode} value={networkSettings.mdns_mode}
onChange={e => handleMdnsModeChange(e.target.value)} onChange={e => handleMdnsModeChange(e.target.value)}
options={filterUnknown([ options={filterUnknown([
{ value: "disabled", label: "Disabled" }, { value: "disabled", label: t('Disabled') },
{ value: "auto", label: "Auto" }, { value: "auto", label: t('Auto') },
{ value: "ipv4_only", label: "IPv4 only" }, { value: "ipv4_only", label: t('IPv4_only') },
{ value: "ipv6_only", label: "IPv6 only" }, { value: "ipv6_only", label: t('IPv6_only') },
])} ])}
/> />
</SettingsItem> </SettingsItem>
@ -338,8 +340,8 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Time synchronization" title={t('Time_synchronization')}
description="Configure time synchronization settings" description={t('Configure_time_synchronization_settings')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -350,9 +352,9 @@ export default function SettingsNetworkRoute() {
options={filterUnknown([ options={filterUnknown([
{ value: "unknown", label: "..." }, { value: "unknown", label: "..." },
// { value: "auto", label: "Auto" }, // { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" }, { value: "ntp_only", label: t('NTP_only') },
{ value: "ntp_and_http", label: "NTP and HTTP" }, { value: "ntp_and_http", label: t('NTP_and_HTTP') },
{ value: "http_only", label: "HTTP only" }, { value: "http_only", label: t('HTTP_only') },
// { value: "custom", label: "Custom" }, // { value: "custom", label: "Custom" },
])} ])}
/> />
@ -363,7 +365,7 @@ export default function SettingsNetworkRoute() {
size="SM" size="SM"
theme="primary" theme="primary"
disabled={firstNetworkSettings.current === networkSettings} disabled={firstNetworkSettings.current === networkSettings}
text="Save Settings" text={t('Save_Settings')}
onClick={() => setNetworkSettingsRemote(networkSettings)} onClick={() => setNetworkSettingsRemote(networkSettings)}
/> />
</div> </div>
@ -371,7 +373,7 @@ export default function SettingsNetworkRoute() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode"> <SettingsItem title={t('IPv4_Mode')} description={t('Configure_the_IPv4_mode')}>
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.ipv4_mode} value={networkSettings.ipv4_mode}
@ -388,7 +390,7 @@ export default function SettingsNetworkRoute() {
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information {t('DHCP_Lease_Information')}
</h3> </h3>
<div className="animate-pulse space-y-3"> <div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" /> <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
@ -406,20 +408,20 @@ export default function SettingsNetworkRoute() {
) : ( ) : (
<EmptyCard <EmptyCard
IconElm={LuEthernetPort} IconElm={LuEthernetPort}
headline="DHCP Information" headline={t('DHCP_Information')}
description="No DHCP lease information available" description={t('No_DHCP_lease_information_available')}
/> />
)} )}
</AutoHeight> </AutoHeight>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode"> <SettingsItem title={t('IPv6_Mode')} description={t('Configure_the_IPv6_mode')}>
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.ipv6_mode} value={networkSettings.ipv6_mode}
onChange={e => handleIpv6ModeChange(e.target.value)} onChange={e => handleIpv6ModeChange(e.target.value)}
options={filterUnknown([ options={filterUnknown([
{ value: "disabled", label: "Disabled" }, { value: "disabled", label: t('Disabled') },
{ value: "slaac", label: "SLAAC" }, { value: "slaac", label: "SLAAC" },
// { value: "dhcpv6", label: "DHCPv6" }, // { value: "dhcpv6", label: "DHCPv6" },
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
@ -435,7 +437,7 @@ export default function SettingsNetworkRoute() {
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information {t('IPv6_Information')}
</h3> </h3>
<div className="animate-pulse space-y-3"> <div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" /> <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
@ -450,8 +452,8 @@ export default function SettingsNetworkRoute() {
) : ( ) : (
<EmptyCard <EmptyCard
IconElm={LuEthernetPort} IconElm={LuEthernetPort}
headline="IPv6 Information" headline={t('IPv6_Information')}
description="No IPv6 addresses configured" description={t('No_IPv6_addresses_configured')}
/> />
)} )}
</AutoHeight> </AutoHeight>
@ -459,16 +461,16 @@ export default function SettingsNetworkRoute() {
<div className="hidden space-y-4"> <div className="hidden space-y-4">
<SettingsItem <SettingsItem
title="LLDP" title="LLDP"
description="Control which TLVs will be sent over Link Layer Discovery Protocol" description={t('Control_which_TLVs_will_be_sent_over_Link_Layer_Discovery_Protocol')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.lldp_mode} value={networkSettings.lldp_mode}
onChange={e => handleLldpModeChange(e.target.value)} onChange={e => handleLldpModeChange(e.target.value)}
options={filterUnknown([ options={filterUnknown([
{ value: "disabled", label: "Disabled" }, { value: "disabled", label: t('Disabled') },
{ value: "basic", label: "Basic" }, { value: "basic", label: t('Basic') },
{ value: "all", label: "All" }, { value: "all", label: t('All') },
])} ])}
/> />
</SettingsItem> </SettingsItem>
@ -477,10 +479,10 @@ export default function SettingsNetworkRoute() {
<ConfirmDialog <ConfirmDialog
open={showRenewLeaseConfirm} open={showRenewLeaseConfirm}
onClose={() => setShowRenewLeaseConfirm(false)} onClose={() => setShowRenewLeaseConfirm(false)}
title="Renew DHCP Lease" title={t('Renew_DHCP_Lease')}
description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process." description={t('This_will_request_a_new_IP_address_from_your_DHCP_server_Your_device_may_temporarily_lose_network_connectivity_during_this_process')}
variant="danger" variant="danger"
confirmText="Renew Lease" confirmText={t('Renew_Lease')}
onConfirm={() => { onConfirm={() => {
handleRenewLease(); handleRenewLease();
setShowRenewLeaseConfirm(false); setShowRenewLeaseConfirm(false);

View File

@ -14,6 +14,7 @@ import {
} from "react-icons/lu"; } from "react-icons/lu";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts"; import { useResizeObserver } from "usehooks-ts";
import { useTranslation } from "react-i18next";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { LinkButton } from "@/components/Button"; import { LinkButton } from "@/components/Button";
@ -70,7 +71,7 @@ export default function SettingsRoute() {
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
}; };
}, [setDisableVideoFocusTrap]); }, [setDisableVideoFocusTrap]);
const { t } = useTranslation();
return ( return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white"> <div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
<div className="h-full"> <div className="h-full">
@ -82,7 +83,7 @@ export default function SettingsRoute() {
to=".." to=".."
size="SM" size="SM"
theme="blank" theme="blank"
text="Back to KVM" text={t('Back_to_KVM')}
LeadingIcon={LuArrowLeft} LeadingIcon={LuArrowLeft}
textAlign="left" textAlign="left"
/> />
@ -92,7 +93,7 @@ export default function SettingsRoute() {
to=".." to=".."
size="SM" size="SM"
theme="blank" theme="blank"
text="Back to KVM" text={t('Back_to_KVM')}
LeadingIcon={LuArrowLeft} LeadingIcon={LuArrowLeft}
textAlign="left" textAlign="left"
fullWidth fullWidth
@ -131,7 +132,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuSettings className="h-4 w-4 shrink-0" /> <LuSettings className="h-4 w-4 shrink-0" />
<h1>General</h1> <h1>{t('General')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -142,7 +143,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuMouse className="h-4 w-4 shrink-0" /> <LuMouse className="h-4 w-4 shrink-0" />
<h1>Mouse</h1> <h1>{t('Mouse')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -154,7 +155,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuKeyboard className="h-4 w-4 shrink-0" /> <LuKeyboard className="h-4 w-4 shrink-0" />
<h1>Keyboard</h1> <h1>{t('Keyboard')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -166,7 +167,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuVideo className="h-4 w-4 shrink-0" /> <LuVideo className="h-4 w-4 shrink-0" />
<h1>Video</h1> <h1>{t('Video')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -177,7 +178,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuCpu className="h-4 w-4 shrink-0" /> <LuCpu className="h-4 w-4 shrink-0" />
<h1>Hardware</h1> <h1>{t('Hardware')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -188,7 +189,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuShieldCheck className="h-4 w-4 shrink-0" /> <LuShieldCheck className="h-4 w-4 shrink-0" />
<h1>Access</h1> <h1>{t('Access')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -199,7 +200,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuPalette className="h-4 w-4 shrink-0" /> <LuPalette className="h-4 w-4 shrink-0" />
<h1>Appearance</h1> <h1>{t('Appearance')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -210,7 +211,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuCommand className="h-4 w-4 shrink-0" /> <LuCommand className="h-4 w-4 shrink-0" />
<h1>Keyboard Macros</h1> <h1>{t('Keyboard_Macros')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -221,7 +222,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuNetwork className="h-4 w-4 shrink-0" /> <LuNetwork className="h-4 w-4 shrink-0" />
<h1>Network</h1> <h1>{t('Network')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
@ -232,7 +233,7 @@ export default function SettingsRoute() {
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuWrench className="h-4 w-4 shrink-0" /> <LuWrench className="h-4 w-4 shrink-0" />
<h1>Advanced</h1> <h1>{t('Advanced')}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea"; import { TextAreaWithLabel } from "@/components/TextArea";
@ -48,6 +49,7 @@ const streamQualityOptions = [
export default function SettingsVideoRoute() { export default function SettingsVideoRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { t } = useTranslation();
const [streamQuality, setStreamQuality] = useState("1"); const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null); const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null); const [edid, setEdid] = useState<string | null>(null);
@ -73,7 +75,7 @@ export default function SettingsVideoRoute() {
send("getEDID", {}, (resp: JsonRpcResponse) => { send("getEDID", {}, (resp: JsonRpcResponse) => {
setEdidLoading(false); setEdidLoading(false);
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`); notifications.error(t('Failed_to_get_EDID_msg',{msg:resp.error.data || t('Unknown_error')}))
return; return;
} }
@ -102,13 +104,13 @@ export default function SettingsVideoRoute() {
(resp: JsonRpcResponse) => { (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`, t('Failed_to_set_stream_quality_msg',{msg:resp.error.data || t('Unknown_error')})
); );
return; return;
} }
notifications.success( notifications.success(
`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`, t('Stream_quality_set_to_msg',{msg:streamQualityOptions.find(x => x.value === factor)?.label})
); );
setStreamQuality(factor); setStreamQuality(factor);
}, },
@ -120,12 +122,12 @@ export default function SettingsVideoRoute() {
send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => { send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => {
setEdidLoading(false); setEdidLoading(false);
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`); notifications.error(t('Failed_to_set_EDID_msg',{msg:resp.error.data || t('Unknown_error')}))
return; return;
} }
notifications.success( notifications.success(
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label ?? "the custom EDID"}`, t('EDID_set_successfully_to_msg',edids.find(x => x.value === newEdid)?.label ?? t('the_custom_EDID'))
); );
// Update the EDID value in the UI // Update the EDID value in the UI
setEdid(newEdid); setEdid(newEdid);
@ -136,15 +138,15 @@ export default function SettingsVideoRoute() {
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Video" title={t('Video')}
description="Configure display settings and EDID for optimal compatibility" description={t('Configure_display_settings_and_EDID_for_optimal_compatibility')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Stream Quality" title={t('Stream_Quality')}
description="Adjust the quality of the video stream" description={t('Adjust_the_quality_of_the_video_stream')}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -157,14 +159,14 @@ export default function SettingsVideoRoute() {
{/* Video Enhancement Settings */} {/* Video Enhancement Settings */}
<SettingsItem <SettingsItem
title="Video Enhancement" title={t('Video_Enhancement')}
description="Adjust color settings to make the video output more vibrant and colorful" description={t('Adjust_color_settings_to_make_the_video_output_more_vibrant_and_colorful')}
/> />
<div className="space-y-4 pl-4"> <div className="space-y-4 pl-4">
<SettingsItem <SettingsItem
title="Saturation" title={t('Saturation')}
description={`Color saturation (${videoSaturation.toFixed(1)}x)`} description={t('Color_saturation_sat_x',{sat:videoSaturation.toFixed(1)})}
> >
<input <input
type="range" type="range"
@ -178,8 +180,8 @@ export default function SettingsVideoRoute() {
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Brightness" title={t('Brightness')}
description={`Brightness level (${videoBrightness.toFixed(1)}x)`} description={t('Brightness_level_brightness',{brightness:videoBrightness.toFixed(1)})}
> >
<input <input
type="range" type="range"
@ -193,8 +195,8 @@ export default function SettingsVideoRoute() {
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="Contrast" title={t('Contrast')}
description={`Contrast level (${videoContrast.toFixed(1)}x)`} description={t('Contrast_level_contrast',{contrast:videoContrast.toFixed(1)})}
> >
<input <input
type="range" type="range"
@ -211,7 +213,7 @@ export default function SettingsVideoRoute() {
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Reset to Default" text={t('Reset_to_Default')}
onClick={() => { onClick={() => {
setVideoSaturation(1.0); setVideoSaturation(1.0);
setVideoBrightness(1.0); setVideoBrightness(1.0);
@ -223,7 +225,7 @@ export default function SettingsVideoRoute() {
<Fieldset disabled={edidLoading} className="space-y-2"> <Fieldset disabled={edidLoading} className="space-y-2">
<SettingsItem <SettingsItem
title="EDID" title="EDID"
description="Adjust the EDID settings for the display" description={t('Adjust_the_EDID_settings_for_the_display')}
loading={edidLoading} loading={edidLoading}
> >
<SelectMenuBasic <SelectMenuBasic
@ -240,17 +242,17 @@ export default function SettingsVideoRoute() {
handleEDIDChange(e.target.value as string); handleEDIDChange(e.target.value as string);
} }
}} }}
options={[...edids, { value: "custom", label: "Custom" }]} options={[...edids, { value: "custom", label: t('Custom') }]}
/> />
</SettingsItem> </SettingsItem>
{customEdidValue !== null && ( {customEdidValue !== null && (
<> <>
<SettingsItem <SettingsItem
title="Custom EDID" title={t('Custom_EDID')}
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments." description={t('EDID_details_video_mode_compatibility_Default_settings_works_in_most_cases')}
/> />
<TextAreaWithLabel <TextAreaWithLabel
label="EDID File" label={t('EDID_File')}
placeholder="00F..." placeholder="00F..."
rows={3} rows={3}
value={customEdidValue} value={customEdidValue}
@ -260,14 +262,14 @@ export default function SettingsVideoRoute() {
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"
text="Set Custom EDID" text={t('Set_Custom_EDID')}
loading={edidLoading} loading={edidLoading}
onClick={() => handleEDIDChange(customEdidValue)} onClick={() => handleEDIDChange(customEdidValue)}
/> />
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Restore to default" text={t('Restore_to_Default')}
loading={edidLoading} loading={edidLoading}
onClick={() => { onClick={() => {
setCustomEdidValue(null); setCustomEdidValue(null);

View File

@ -1,5 +1,6 @@
import { Form, redirect, useActionData, useParams, useSearchParams } from "react-router"; import { Form, redirect, useActionData, useParams, useSearchParams } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router"; import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from "react-router";
import { useTranslation } from "react-i18next";
import SimpleNavbar from "@components/SimpleNavbar"; import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
@ -41,6 +42,7 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
}; };
export default function SetupRoute() { export default function SetupRoute() {
const { t } = useTranslation();
const action = useActionData() as { error?: string }; const action = useActionData() as { error?: string };
const { id } = useParams() as { id: string }; const { id } = useParams() as { id: string };
const [sp] = useSearchParams(); const [sp] = useSearchParams();
@ -59,20 +61,19 @@ export default function SetupRoute() {
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Let&apos;s name your device</h1> <h1 className="text-4xl font-semibold text-black dark:text-white">{t('Lets_name_your_device')}</h1>
<p className="text-slate-600 dark:text-slate-400"> <p className="text-slate-600 dark:text-slate-400">
Name your device so you can easily identify it later. You can change {t('Name_your_device_so_you_can_easily_identify_it_later_You_can_change_this_name_at_any_time')}
this name at any time.
</p> </p>
</div> </div>
<Fieldset className="space-y-12"> <Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4"> <Form method="POST" className="max-w-sm mx-auto space-y-4">
<InputFieldWithLabel <InputFieldWithLabel
label="Device Name" label={t('Device_Name')}
type="text" type="text"
name="name" name="name"
placeholder="Plex Media Server" placeholder={t('Plex_Media_Server')}
autoFocus autoFocus
data-1p-ignore data-1p-ignore
autoComplete="organization" autoComplete="organization"
@ -86,7 +87,7 @@ export default function SetupRoute() {
theme="primary" theme="primary"
fullWidth fullWidth
type="submit" type="submit"
text="Finish Setup" text={t('Finish_Setup')}
textAlign="center" textAlign="center"
/> />
</Form> </Form>

View File

@ -14,6 +14,7 @@ import { useInterval } from "usehooks-ts";
import { FocusTrap } from "focus-trap-react"; import { FocusTrap } from "focus-trap-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket"; import useWebSocket from "react-use-websocket";
import { useTranslation } from "react-i18next";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api"; import api from "@/api";
@ -114,6 +115,7 @@ const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => {
}; };
export default function KvmIdRoute() { export default function KvmIdRoute() {
const { t } = useTranslation();
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp; const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
// Depending on the mode, we set the appropriate variables // Depending on the mode, we set the appropriate variables
const user = "user" in loaderResp ? loaderResp.user : null; const user = "user" in loaderResp ? loaderResp.user : null;
@ -145,7 +147,7 @@ export default function KvmIdRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore(); const { otaState, setOtaState, setModalView } = useUpdateStore();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const [loadingMessage, setLoadingMessage] = useState(t("Connecting_to_device"));
const cleanupAndStopReconnecting = useCallback( const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() { function cleanupAndStopReconnecting() {
console.log("Closing peer connection"); console.log("Closing peer connection");
@ -182,12 +184,12 @@ export default function KvmIdRoute() {
pc: RTCPeerConnection, pc: RTCPeerConnection,
remoteDescription: RTCSessionDescriptionInit, remoteDescription: RTCSessionDescriptionInit,
) { ) {
setLoadingMessage("Setting remote description"); setLoadingMessage(t('Setting_remote_description'));
try { try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription)); await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully"); console.log("[setRemoteSessionDescription] Remote description set successfully");
setLoadingMessage("Establishing secure connection..."); setLoadingMessage(t('Establishing_secure_connection'));
} catch (error) { } catch (error) {
console.error( console.error(
"[setRemoteSessionDescription] Failed to set remote description:", "[setRemoteSessionDescription] Failed to set remote description:",
@ -206,7 +208,7 @@ export default function KvmIdRoute() {
if (pc.sctp?.state === "connected") { if (pc.sctp?.state === "connected") {
console.log("[setRemoteSessionDescription] Remote description set"); console.log("[setRemoteSessionDescription] Remote description set");
clearInterval(checkInterval); clearInterval(checkInterval);
setLoadingMessage("Connection established"); setLoadingMessage(t('Connection_established'));
} else if (attempts >= 10) { } else if (attempts >= 10) {
console.warn( console.warn(
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts", "[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
@ -365,8 +367,10 @@ export default function KvmIdRoute() {
console.log("Trying to get remote session description"); console.log("Trying to get remote session description");
setLoadingMessage( setLoadingMessage(
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`, t('Getting_remote_session_description',{
); attempt:signalingAttempts.current > 0 ? t('attempt_num',{num:signalingAttempts.current + 1}) : ''
})
);//Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
const res = await api.POST(sessionUrl, { const res = await api.POST(sessionUrl, {
sd, sd,
// When on device, we don't need to specify the device id, as it's already known // When on device, we don't need to specify the device id, as it's already known
@ -382,7 +386,7 @@ export default function KvmIdRoute() {
} }
console.debug("Successfully got Remote Session Description. Setting."); console.debug("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description..."); setLoadingMessage(t('Setting_remote_session_description'));
const decodedSd = atob(json.sd); const decodedSd = atob(json.sd);
const parsedSd = JSON.parse(decodedSd); const parsedSd = JSON.parse(decodedSd);
@ -394,12 +398,12 @@ export default function KvmIdRoute() {
const setupPeerConnection = useCallback(async () => { const setupPeerConnection = useCallback(async () => {
console.debug("[setupPeerConnection] Setting up peer connection"); console.debug("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false); setConnectionFailed(false);
setLoadingMessage("Connecting to device..."); setLoadingMessage(t('Connecting_to_device'));
let pc: RTCPeerConnection; let pc: RTCPeerConnection;
try { try {
console.debug("[setupPeerConnection] Creating peer connection"); console.debug("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection..."); setLoadingMessage(t('Creating_peer_connection'));
pc = new RTCPeerConnection({ pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud // We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers ...(isInCloud && iceConfig?.iceServers
@ -409,7 +413,7 @@ export default function KvmIdRoute() {
setPeerConnectionState(pc.connectionState); setPeerConnectionState(pc.connectionState);
console.debug("[setupPeerConnection] Peer connection created", pc); console.debug("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device..."); setLoadingMessage(t('Setting_up_connection_to_device'));
} catch (e) { } catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`); console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
setTimeout(() => { setTimeout(() => {
@ -459,7 +463,7 @@ export default function KvmIdRoute() {
const pc = event.currentTarget as RTCPeerConnection; const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") { if (pc.iceGatheringState === "complete") {
console.debug("ICE Gathering completed"); console.debug("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed"); setLoadingMessage(t('ICE_Gathering_completed').toString());
if (isLegacySignalingEnabled.current) { if (isLegacySignalingEnabled.current) {
// We can now start the https/ws connection to get the remote session description from the KVM device // We can now start the https/ws connection to get the remote session description from the KVM device
@ -467,7 +471,7 @@ export default function KvmIdRoute() {
} }
} else if (pc.iceGatheringState === "gathering") { } else if (pc.iceGatheringState === "gathering") {
console.debug("ICE Gathering Started"); console.debug("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates..."); setLoadingMessage(t('Gathering_ICE_candidates').toString());
} }
}; };
@ -832,12 +836,12 @@ export default function KvmIdRoute() {
<div className="grid h-full grid-rows-(--grid-headerBody) select-none"> <div className="grid h-full grid-rows-(--grid-headerBody) select-none">
<DashboardNavbar <DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={isOnDevice ? [] : [{ title: t('Cloud_Devices'), to: "/devices" }]}
showConnectionStatus={true} showConnectionStatus={true}
isLoggedIn={authMode === "password" || !!user} isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email} userEmail={user?.email}
picture={user?.picture} picture={user?.picture}
kvmName={deviceName ?? "JetKVM Device"} kvmName={deviceName ?? t('JetKVM_Device')}
/> />
<div className="relative flex h-full w-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
@ -873,11 +877,11 @@ export default function KvmIdRoute() {
</div> </div>
{kvmTerminal && ( {kvmTerminal && (
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" /> <Terminal type="kvm" dataChannel={kvmTerminal} title={t('KVM_Terminal')} />
)} )}
{serialConsole && ( {serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" /> <Terminal type="serial" dataChannel={serialConsole} title={t('Serial_Console')} />
)} )}
</FeatureFlagProvider> </FeatureFlagProvider>
); );

View File

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

View File

@ -3,6 +3,7 @@ import type { LoaderFunction } from "react-router";
import { LuMonitorSmartphone } from "react-icons/lu"; import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import { useTranslation } from "react-i18next";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import EmptyCard from "@components/EmptyCard"; import EmptyCard from "@components/EmptyCard";
@ -36,6 +37,7 @@ const loader: LoaderFunction = async () => {
}; };
export default function DevicesRoute() { export default function DevicesRoute() {
const { t } = useTranslation();
const { devices, user } = useLoaderData() as LoaderData; const { devices, user } = useLoaderData() as LoaderData;
const revalidate = useRevalidator(); const revalidate = useRevalidator();
useInterval(revalidate.revalidate, 4000); useInterval(revalidate.revalidate, 4000);
@ -44,7 +46,7 @@ export default function DevicesRoute() {
<div className="grid h-full select-none grid-rows-(--grid-headerBody)"> <div className="grid h-full select-none grid-rows-(--grid-headerBody)">
<DashboardNavbar <DashboardNavbar
isLoggedIn={!!user} isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={[{ title: t('Cloud_Devices'), to: "/devices" }]}
userEmail={user?.email} userEmail={user?.email}
picture={user?.picture} picture={user?.picture}
/> />
@ -54,10 +56,10 @@ export default function DevicesRoute() {
<div className="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20"> <div className="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20">
<div> <div>
<h1 className="text-xl font-bold text-black dark:text-white"> <h1 className="text-xl font-bold text-black dark:text-white">
Cloud KVMs {t('Cloud_KVMs')}
</h1> </h1>
<p className="text-base text-slate-700 dark:text-slate-400"> <p className="text-base text-slate-700 dark:text-slate-400">
Manage your cloud KVMs and connect to them securely. {t('Manage_your_cloud_KVMs_and_connect_to_them_securely')}
</p> </p>
</div> </div>
</div> </div>
@ -66,15 +68,15 @@ export default function DevicesRoute() {
<div className="max-w-3xl"> <div className="max-w-3xl">
<EmptyCard <EmptyCard
IconElm={LuMonitorSmartphone} IconElm={LuMonitorSmartphone}
headline="No devices found" headline={t('No_devices_found')}
description="You don't have any devices with enabled JetKVM Cloud yet." description={t('You_don_t_have_any_devices_with_enabled_JetKVM_Cloud_yet')}
BtnElm={ BtnElm={
<LinkButton <LinkButton
to="https://jetkvm.com/docs/networking/remote-access" to="https://jetkvm.com/docs/networking/remote-access"
size="SM" size="SM"
theme="primary" theme="primary"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
text="Learn more" text={t('Learn_more')}
/> />
} }
/> />

View File

@ -2,6 +2,7 @@ import { Form, redirect, useActionData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router"; import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router";
import { useState } from "react"; import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import SimpleNavbar from "@components/SimpleNavbar"; import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
@ -31,6 +32,7 @@ const loader: LoaderFunction = async () => {
}; };
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const { t } = useTranslation();
const formData = await request.formData(); const formData = await request.formData();
const password = formData.get("password"); const password = formData.get("password");
@ -42,15 +44,16 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
if (response.ok) { if (response.ok) {
return redirect("/"); return redirect("/");
} else { } else {
return { error: "Invalid password" }; return { error: t('Invalid_password') };
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return { error: "An error occurred while logging in" }; return { error: t('An_error_occurred_while_logging_in') };
} }
}; };
export default function LoginLocalRoute() { export default function LoginLocalRoute() {
const { t } = useTranslation();
const actionData = useActionData() as { error?: string; success?: boolean }; const actionData = useActionData() as { error?: string; success?: boolean };
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@ -73,10 +76,10 @@ export default function LoginLocalRoute() {
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white"> <h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome back to JetKVM {t('Welcome_back_to_JetKVM')}
</h1> </h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Enter your password to access your JetKVM. {t('Enter_your_password_to_access_your_JetKVM')}
</p> </p>
</div> </div>
@ -84,11 +87,11 @@ export default function LoginLocalRoute() {
<Form method="POST" className="mx-auto max-w-sm space-y-4"> <Form method="POST" className="mx-auto max-w-sm space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<InputFieldWithLabel <InputFieldWithLabel
label="Password" label={t('Password')}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
name="password" name="password"
autoComplete="current-password" autoComplete="current-password"
placeholder="Enter your password" placeholder={t('Enter_your_password')}
autoFocus autoFocus
error={actionData?.error} error={actionData?.error}
TrailingElm={ TrailingElm={
@ -116,7 +119,7 @@ export default function LoginLocalRoute() {
theme="primary" theme="primary"
fullWidth fullWidth
type="submit" type="submit"
text="Log In" text={t('Log_In')}
textAlign="center" textAlign="center"
/> />
@ -125,7 +128,7 @@ export default function LoginLocalRoute() {
href="https://jetkvm.com/docs/networking/local-access#reset-password" href="https://jetkvm.com/docs/networking/local-access#reset-password"
className="hover:underline" className="hover:underline"
> >
Forgot password? {t('Forgot_password')}
</ExtLink> </ExtLink>
</div> </div>
</Form> </Form>

View File

@ -1,8 +1,10 @@
import { useTranslation } from "react-i18next";
import { useLocation, useSearchParams } from "react-router"; import { useLocation, useSearchParams } from "react-router";
import AuthLayout from "@components/AuthLayout"; import AuthLayout from "@components/AuthLayout";
export default function LoginRoute() { export default function LoginRoute() {
const { t } = useTranslation();
const [sq] = useSearchParams(); const [sq] = useSearchParams();
const location = useLocation(); const location = useLocation();
const deviceId = sq.get("deviceId") || location.state?.deviceId; const deviceId = sq.get("deviceId") || location.state?.deviceId;
@ -11,11 +13,11 @@ export default function LoginRoute() {
return ( return (
<AuthLayout <AuthLayout
showCounter={true} showCounter={true}
title="Connect your JetKVM to the cloud" title={t('Connect_your_JetKVM_to_the_cloud')}
description="Unlock remote access and advanced features for your device" description={t('Unlock_remote_access_and_advanced_features_for_your_device')}
action="Log in & Connect device" action={t('Log_in_Connect_device')}
// Header CTA // Header CTA
cta="Don't have an account?" cta={t('Dont_have_an_account')}
ctaHref={`/signup?${sq.toString()}`} ctaHref={`/signup?${sq.toString()}`}
/> />
); );
@ -23,11 +25,11 @@ export default function LoginRoute() {
return ( return (
<AuthLayout <AuthLayout
title="Log in to your JetKVM account" title={t('Log_in_to_your_JetKVM_account')}
description="Log in to access and manage your devices securely" description={t('Log_in_to_access_and_manage_your_devices_securely')}
action="Log in" action={t('Log_In')}
// Header CTA // Header CTA
cta="New to JetKVM?" cta={t('New_to_JetKVM')}
ctaHref={`/signup?${sq.toString()}`} ctaHref={`/signup?${sq.toString()}`}
/> />
); );

View File

@ -1,8 +1,10 @@
import { useLocation, useSearchParams } from "react-router"; import { useLocation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import AuthLayout from "@components/AuthLayout"; import AuthLayout from "@components/AuthLayout";
export default function SignupRoute() { export default function SignupRoute() {
const { t } = useTranslation();
const [sq] = useSearchParams(); const [sq] = useSearchParams();
const location = useLocation(); const location = useLocation();
const deviceId = sq.get("deviceId") || location.state?.deviceId; const deviceId = sq.get("deviceId") || location.state?.deviceId;
@ -11,10 +13,10 @@ export default function SignupRoute() {
return ( return (
<AuthLayout <AuthLayout
showCounter={true} showCounter={true}
title="Connect your JetKVM to the cloud" title={t('Connect_your_JetKVM_to_the_cloud')}
description="Unlock remote access and advanced features for your device." description={t('Unlock_remote_access_and_advanced_features_for_your_device')}
action="Signup & Connect device" action={t('Signup_Connect_device')}
cta="Already have an account?" cta={t('Already_have_an_account')}
ctaHref={`/login?${sq.toString()}`} ctaHref={`/login?${sq.toString()}`}
/> />
); );
@ -22,11 +24,11 @@ export default function SignupRoute() {
return ( return (
<AuthLayout <AuthLayout
title="Create your JetKVM account" title={t('Create_your_JetKVM_account')}
description="Create your account and start managing your devices with ease." description={t('Create_your_account_and_start_managing_your_devices_with_ease')}
action="Create Account" action={t('Create_Account')}
// Header CTA // Header CTA
cta="Already have an account?" cta={t('Already_have_an_account')}
ctaHref={`/login?${sq.toString()}`} ctaHref={`/login?${sq.toString()}`}
/> />
); );

View File

@ -1,6 +1,7 @@
import { Form, redirect, useActionData } from "react-router"; import { Form, redirect, useActionData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router"; import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
@ -25,9 +26,10 @@ const loader: LoaderFunction = async () => {
}; };
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const { t } = useTranslation();
const formData = await request.formData(); const formData = await request.formData();
const localAuthMode = formData.get("localAuthMode"); const localAuthMode = formData.get("localAuthMode");
if (!localAuthMode) return { error: "Please select an authentication mode" }; if (!localAuthMode) return { error: t('Please_select_an_authentication_mode') };
if (localAuthMode === "password") { if (localAuthMode === "password") {
return redirect("/welcome/password"); return redirect("/welcome/password");
@ -41,14 +43,15 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
return redirect("/"); return redirect("/");
} catch (error) { } catch (error) {
console.error("Error setting authentication mode:", error); console.error("Error setting authentication mode:", error);
return { error: "An error occurred while setting the authentication mode" }; return { error: t('An_error_occurred_while_setting_the_authentication_mode') };
} }
} }
return { error: "Invalid authentication mode" }; return { error: t('Invalid_authentication_mode') };
}; };
export default function WelcomeLocalModeRoute() { export default function WelcomeLocalModeRoute() {
const { t } = useTranslation();
const actionData = useActionData() as { error?: string }; const actionData = useActionData() as { error?: string };
const [selectedMode, setSelectedMode] = useState<"password" | "noPassword" | null>( const [selectedMode, setSelectedMode] = useState<"password" | "noPassword" | null>(
null, null,
@ -75,10 +78,10 @@ export default function WelcomeLocalModeRoute() {
style={{ animationDelay: "200ms" }} style={{ animationDelay: "200ms" }}
> >
<h1 className="text-4xl font-semibold text-black dark:text-white"> <h1 className="text-4xl font-semibold text-black dark:text-white">
Local Authentication Method {t('Local_Authentication_Method')}
</h1> </h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Select how you{"'"}d like to secure your JetKVM device locally. {t('Select_how_you_d_like_to_secure_your_JetKVM_device_locally')}
</p> </p>
</div> </div>
@ -101,12 +104,12 @@ export default function WelcomeLocalModeRoute() {
> >
<div className="space-y-0 text-center"> <div className="space-y-0 text-center">
<h3 className="text-base font-bold text-black dark:text-white"> <h3 className="text-base font-bold text-black dark:text-white">
{mode === "password" ? "Password protected" : "No Password"} {mode === "password" ? t('Password_protected') : t('No_password')}
</h3> </h3>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> <p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{mode === "password" {mode === "password"
? "Secure your device with a password for added protection." ? t('Secure_your_device_with_a_password_for_added_protection')
: "Quick access without password authentication."} : t('Quick_access_without_password_authentication')}
</p> </p>
</div> </div>
<input <input
@ -142,7 +145,7 @@ export default function WelcomeLocalModeRoute() {
theme="primary" theme="primary"
fullWidth fullWidth
type="submit" type="submit"
text="Continue" text={t('Continue')}
textAlign="center" textAlign="center"
disabled={!selectedMode} disabled={!selectedMode}
/> />
@ -153,7 +156,7 @@ export default function WelcomeLocalModeRoute() {
className="animate-fadeIn mx-auto max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400" className="animate-fadeIn mx-auto max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "600ms" }} style={{ animationDelay: "600ms" }}
> >
You can always change your authentication method later in the settings. {t('You_can_always_change_your_authentication_method_later_in_the_settings')}
</p> </p>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { Form, redirect, useActionData } from "react-router";
import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router"; import type { ActionFunction, ActionFunctionArgs, LoaderFunction } from "react-router";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
@ -26,12 +27,13 @@ const loader: LoaderFunction = async () => {
}; };
const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
const { t } = useTranslation();
const formData = await request.formData(); const formData = await request.formData();
const password = formData.get("password"); const password = formData.get("password");
const confirmPassword = formData.get("confirmPassword"); const confirmPassword = formData.get("confirmPassword");
if (password !== confirmPassword) { if (password !== confirmPassword) {
return { error: "Passwords do not match" }; return { error: t('Passwords_do_not_match') };
} }
try { try {
@ -43,15 +45,16 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => {
if (response.ok) { if (response.ok) {
return redirect("/"); return redirect("/");
} else { } else {
return { error: "Failed to set password" }; return { error: t('Failed_to_set_password') };
} }
} catch (error) { } catch (error) {
console.error("Error setting password:", error); console.error("Error setting password:", error);
return { error: "An error occurred while setting the password" }; return { error: t('An_error_occurred_while_setting_the_password') };
} }
}; };
export default function WelcomeLocalPasswordRoute() { export default function WelcomeLocalPasswordRoute() {
const { t } = useTranslation();
const actionData = useActionData() as { error?: string }; const actionData = useActionData() as { error?: string };
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const passwordInputRef = useRef<HTMLInputElement>(null); const passwordInputRef = useRef<HTMLInputElement>(null);
@ -86,10 +89,10 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "200ms" }} style={{ animationDelay: "200ms" }}
> >
<h1 className="text-4xl font-semibold text-black dark:text-white"> <h1 className="text-4xl font-semibold text-black dark:text-white">
Set a Password {t('Set_a_Password')}
</h1> </h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Create a strong password to secure your JetKVM device locally. {t('Create_a_strong_password_to_secure_your_JetKVM_device_locally')}
</p> </p>
</div> </div>
@ -101,10 +104,10 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
> >
<InputFieldWithLabel <InputFieldWithLabel
label="Password" label={t('Password')}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
name="password" name="password"
placeholder="Enter a password" placeholder={t('Enter_a_password')}
autoComplete="new-password" autoComplete="new-password"
ref={passwordInputRef} ref={passwordInputRef}
TrailingElm={ TrailingElm={
@ -131,11 +134,11 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
> >
<InputFieldWithLabel <InputFieldWithLabel
label="Confirm Password" label={t('Confirm_Password')}
autoComplete="new-password" autoComplete="new-password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
name="confirmPassword" name="confirmPassword"
placeholder="Confirm your password" placeholder={t('Confirm_your_password')}
error={actionData?.error} error={actionData?.error}
/> />
</div> </div>
@ -152,7 +155,7 @@ export default function WelcomeLocalPasswordRoute() {
theme="primary" theme="primary"
fullWidth fullWidth
type="submit" type="submit"
text="Set Password" text={t('Set_Password')}
textAlign="center" textAlign="center"
/> />
</div> </div>
@ -163,9 +166,8 @@ export default function WelcomeLocalPasswordRoute() {
className="animate-fadeIn max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400" className="animate-fadeIn max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "800ms" }} style={{ animationDelay: "800ms" }}
> >
This password will be used to secure your device data and protect against {t('This_password_will_be_used_to_secure_your_device_data_and_protect_against_unauthorized_access')}
unauthorized access.{" "} <span className="font-bold">{t('All_data_remains_on_your_local_device')}</span>
<span className="font-bold">All data remains on your local device.</span>
</p> </p>
</div> </div>
</div> </div>

View File

@ -13,6 +13,7 @@ import LogoMark from "@/assets/logo-mark.png";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import api from "../api"; import api from "../api";
import {useTranslation} from "react-i18next";
export interface DeviceStatus { export interface DeviceStatus {
isSetup: boolean; isSetup: boolean;
@ -35,7 +36,7 @@ export default function WelcomeRoute() {
img.src = DeviceImage; img.src = DeviceImage;
img.onload = () => setImageLoaded(true); img.onload = () => setImageLoaded(true);
}, []); }, []);
const { t } = useTranslation();
return ( return (
<> <>
<GridBackground /> <GridBackground />
@ -61,10 +62,10 @@ export default function WelcomeRoute() {
<div className="animate-fadeIn animation-delay-1500 space-y-1 opacity-0"> <div className="animate-fadeIn animation-delay-1500 space-y-1 opacity-0">
<h1 className="text-4xl font-semibold text-black dark:text-white"> <h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome to JetKVM {t('Welcome_to_JetKVM')}
</h1> </h1>
<p className="text-lg font-medium text-slate-600 dark:text-slate-400"> <p className="text-lg font-medium text-slate-600 dark:text-slate-400">
Control any computer remotely {t('Control_any_computer_remotely')}
</p> </p>
</div> </div>
</div> </div>
@ -72,7 +73,7 @@ export default function WelcomeRoute() {
<div className="-mt-2! -ml-6 flex items-center justify-center"> <div className="-mt-2! -ml-6 flex items-center justify-center">
<img <img
src={DeviceImage} src={DeviceImage}
alt="JetKVM Device" alt={t('JetKVM_Device')}
className="animation-delay-300 animate-fadeInScaleFloat max-w-md scale-[0.98] opacity-0 transition-all duration-1000 ease-out" className="animation-delay-300 animate-fadeInScaleFloat max-w-md scale-[0.98] opacity-0 transition-all duration-1000 ease-out"
/> />
</div> </div>
@ -82,14 +83,13 @@ export default function WelcomeRoute() {
style={{ animationDelay: "2000ms" }} style={{ animationDelay: "2000ms" }}
className="animate-fadeIn mx-auto max-w-lg text-lg text-slate-700 opacity-0 dark:text-slate-300" className="animate-fadeIn mx-auto max-w-lg text-lg text-slate-700 opacity-0 dark:text-slate-300"
> >
JetKVM combines powerful hardware with intuitive software to provide a {t('JetKVM_combines_powerful_hardware_with_intuitive_software_to_provide_a_seamless_remote_control_experience')}
seamless remote control experience.
</p> </p>
<div className="animate-fadeIn animation-delay-2300 opacity-0"> <div className="animate-fadeIn animation-delay-2300 opacity-0">
<LinkButton <LinkButton
size="LG" size="LG"
theme="light" theme="light"
text="Set up your JetKVM" text={t('Set_up_your_JetKVM')}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<img src={LogoMark} className={cx(className, "mr-1.5 h-5!")} /> <img src={LogoMark} className={cx(className, "mr-1.5 h-5!")} />
)} )}

View File

@ -5,64 +5,65 @@ import tsconfigPaths from "vite-tsconfig-paths";
import basicSsl from "@vitejs/plugin-basic-ssl"; import basicSsl from "@vitejs/plugin-basic-ssl";
declare const process: { declare const process: {
env: { env: {
JETKVM_PROXY_URL: string; JETKVM_PROXY_URL: string;
USE_SSL: string; USE_SSL: string;
}; };
}; };
// @ts-ignore
export default defineConfig(({ mode, command }) => { export default defineConfig(({ mode, command }) => {
const isCloud = mode.indexOf("cloud") !== -1; const isCloud = mode.indexOf("cloud") !== -1;
const onDevice = mode === "device"; const onDevice = mode === "device";
const { JETKVM_PROXY_URL, USE_SSL } = process.env; const { JETKVM_PROXY_URL, USE_SSL } = process.env;
const useSSL = USE_SSL === "true"; const useSSL = USE_SSL === "true";
const plugins = [ const plugins = [
tailwindcss(), tailwindcss(),
tsconfigPaths(), tsconfigPaths(),
react() react()
]; ];
if (useSSL) { if (useSSL) {
plugins.push(basicSsl()); plugins.push(basicSsl());
} }
return { return {
plugins, plugins,
esbuild: { esbuild: {
pure: ["console.debug"], pure: ["console.debug"],
},
assetsInclude: ["**/*.woff2"],
build: {
outDir: isCloud ? "dist" : "../static",
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes("node_modules")) {
return "vendor";
}
return null;
},
assetFileNames: "assets/immutable/[name]-[hash][extname]",
chunkFileNames: "assets/immutable/[name]-[hash].js",
entryFileNames: "assets/immutable/[name]-[hash].js",
}, },
}, assetsInclude: ["**/*.woff2","**/*.json"],
}, build: {
server: { outDir: isCloud ? "dist" : "../static",
host: "0.0.0.0", rollupOptions: {
https: useSSL, output: {
proxy: JETKVM_PROXY_URL manualChunks: (id) => {
? { if (id.includes("node_modules")) {
"/me": JETKVM_PROXY_URL, return "vendor";
"/device": JETKVM_PROXY_URL, }
"/webrtc": JETKVM_PROXY_URL, return null;
"/auth": JETKVM_PROXY_URL, },
"/storage": JETKVM_PROXY_URL, assetFileNames: "assets/immutable/[name]-[hash][extname]",
"/cloud": JETKVM_PROXY_URL, chunkFileNames: "assets/immutable/[name]-[hash].js",
"/developer": JETKVM_PROXY_URL, entryFileNames: "assets/immutable/[name]-[hash].js",
} },
: undefined, },
}, },
base: onDevice && command === "build" ? "/static" : "/", server: {
}; host: "0.0.0.0",
https: useSSL,
proxy: JETKVM_PROXY_URL
? {
"/me": JETKVM_PROXY_URL,
"/device": JETKVM_PROXY_URL,
"/webrtc": JETKVM_PROXY_URL,
"/auth": JETKVM_PROXY_URL,
"/storage": JETKVM_PROXY_URL,
"/cloud": JETKVM_PROXY_URL,
"/developer": JETKVM_PROXY_URL,
}
: undefined,
},
base: onDevice && command === "build" ? "/static" : "/",
};
}); });