mirror of https://github.com/jetkvm/kvm.git
Compare commits
25 Commits
88b22a4378
...
70f7ce5b0b
Author | SHA1 | Date |
---|---|---|
|
70f7ce5b0b | |
|
22566e0450 | |
|
978bef420c | |
|
48240eebe0 | |
|
4d840b65a9 | |
|
aeaed88af5 | |
|
e4ddc952d1 | |
|
2f048ef38f | |
|
7c2b91a9c4 | |
|
435746f35e | |
|
842fd22072 | |
|
c5b80761ce | |
|
1a85f4d8ad | |
|
98485430eb | |
|
5447e3434d | |
|
33905e6378 | |
|
a364a06a3a | |
|
77ce41a5ea | |
|
baed361ae6 | |
|
341b70ff0a | |
|
3887f7e5b5 | |
|
2be96d327c | |
|
34f48f9bea | |
|
962a6f6dfc | |
|
c3087abe02 |
|
@ -4,7 +4,7 @@
|
|||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
// Should match what is defined in ui/package.json
|
||||
"version": "22.15.0"
|
||||
"version": "21.1.0"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
|
|
|
@ -90,7 +90,6 @@ type Config struct {
|
|||
KeyboardLayout string `json:"keyboard_layout"`
|
||||
EdidString string `json:"hdmi_edid_string"`
|
||||
ActiveExtension string `json:"active_extension"`
|
||||
DisplayRotation string `json:"display_rotation"`
|
||||
DisplayMaxBrightness int `json:"display_max_brightness"`
|
||||
DisplayDimAfterSec int `json:"display_dim_after_sec"`
|
||||
DisplayOffAfterSec int `json:"display_off_after_sec"`
|
||||
|
@ -109,8 +108,7 @@ var defaultConfig = &Config{
|
|||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
DisplayRotation: "270",
|
||||
KeyboardLayout: "en-US",
|
||||
KeyboardLayout: "en_US",
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
|
|
|
@ -24,7 +24,6 @@ show_help() {
|
|||
REMOTE_USER="root"
|
||||
REMOTE_PATH="/userdata/jetkvm/bin"
|
||||
SKIP_UI_BUILD=false
|
||||
RESET_USB_HID_DEVICE=false
|
||||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
||||
|
||||
# Parse command line arguments
|
||||
|
@ -42,10 +41,6 @@ while [[ $# -gt 0 ]]; do
|
|||
SKIP_UI_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--reset-usb-hid)
|
||||
RESET_USB_HID_DEVICE=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
|
@ -79,12 +74,6 @@ ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
|||
# Copy the binary to the remote host
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
|
||||
|
||||
if [ "$RESET_USB_HID_DEVICE" = true ]; then
|
||||
# Remove the old USB gadget configuration
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
|
||||
fi
|
||||
|
||||
# Deploy and run the application on the remote host
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
||||
set -e
|
||||
|
|
|
@ -73,10 +73,6 @@ func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
|
|||
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
|
||||
}
|
||||
|
||||
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
|
||||
}
|
||||
|
||||
func updateLabelIfChanged(objName string, newText string) {
|
||||
if newText != "" && newText != displayedTexts[objName] {
|
||||
_, _ = lvLabelSetText(objName, newText)
|
||||
|
@ -377,7 +373,6 @@ func init() {
|
|||
waitCtrlClientConnected()
|
||||
displayLogger.Info().Msg("setting initial display contents")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = lvDispSetRotation(config.DisplayRotation)
|
||||
updateStaticContents()
|
||||
displayInited = true
|
||||
displayLogger.Info().Msg("display inited")
|
||||
|
|
|
@ -13,8 +13,7 @@ var defaultNTPServers = []string{
|
|||
"time.aws.com",
|
||||
"time.windows.com",
|
||||
"time.google.com",
|
||||
"162.159.200.123", // time.cloudflare.com IPv4
|
||||
"2606:4700:f1::123", // time.cloudflare.com IPv6
|
||||
"162.159.200.123", // time.cloudflare.com
|
||||
"0.pool.ntp.org",
|
||||
"1.pool.ntp.org",
|
||||
"2.pool.ntp.org",
|
||||
|
@ -58,13 +57,6 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
|
|||
|
||||
// query the server
|
||||
now, response, err := queryNtpServer(server, timeout)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().
|
||||
Str("error", err.Error()).
|
||||
Msg("failed to query NTP server")
|
||||
results <- nil
|
||||
return
|
||||
}
|
||||
|
||||
// set the last RTT
|
||||
metricNtpServerLastRTT.WithLabelValues(
|
||||
|
@ -84,33 +76,32 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
|
|||
strconv.Itoa(int(response.Precision)),
|
||||
).Set(1)
|
||||
|
||||
// increase success count
|
||||
metricNtpTotalSuccessCount.Inc()
|
||||
metricNtpSuccessCount.WithLabelValues(server).Inc()
|
||||
if err == nil {
|
||||
// increase success count
|
||||
metricNtpTotalSuccessCount.Inc()
|
||||
metricNtpSuccessCount.WithLabelValues(server).Inc()
|
||||
|
||||
scopedLogger.Info().
|
||||
Str("time", now.Format(time.RFC3339)).
|
||||
Str("reference", response.ReferenceString()).
|
||||
Str("rtt", response.RTT.String()).
|
||||
Str("clockOffset", response.ClockOffset.String()).
|
||||
Uint8("stratum", response.Stratum).
|
||||
Msg("NTP server returned time")
|
||||
results <- &ntpResult{
|
||||
now: now,
|
||||
offset: &response.ClockOffset,
|
||||
scopedLogger.Info().
|
||||
Str("time", now.Format(time.RFC3339)).
|
||||
Str("reference", response.ReferenceString()).
|
||||
Str("rtt", response.RTT.String()).
|
||||
Str("clockOffset", response.ClockOffset.String()).
|
||||
Uint8("stratum", response.Stratum).
|
||||
Msg("NTP server returned time")
|
||||
results <- &ntpResult{
|
||||
now: now,
|
||||
offset: &response.ClockOffset,
|
||||
}
|
||||
} else {
|
||||
scopedLogger.Warn().
|
||||
Str("error", err.Error()).
|
||||
Msg("failed to query NTP server")
|
||||
}
|
||||
}(server)
|
||||
}
|
||||
|
||||
for range servers {
|
||||
result := <-results
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
now, offset = result.now, result.offset
|
||||
return
|
||||
}
|
||||
return
|
||||
result := <-results
|
||||
return result.now, result.offset
|
||||
}
|
||||
|
||||
func queryNtpServer(server string, timeout time.Duration) (now *time.Time, response *ntp.Response, err error) {
|
||||
|
|
|
@ -137,29 +137,6 @@ func (u *UsbGadget) GetPath(itemKey string) (string, error) {
|
|||
return joinPath(u.kvmGadgetPath, item.path), nil
|
||||
}
|
||||
|
||||
// OverrideGadgetConfig overrides the gadget config for the given item and attribute.
|
||||
// It returns an error if the item is not found or the attribute is not found.
|
||||
// It returns true if the attribute is overridden, false otherwise.
|
||||
func (u *UsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value string) (error, bool) {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
|
||||
// get it as a pointer
|
||||
_, ok := u.configMap[itemKey]
|
||||
if !ok {
|
||||
return fmt.Errorf("config item %s not found", itemKey), false
|
||||
}
|
||||
|
||||
if u.configMap[itemKey].attrs[itemAttr] == value {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
u.configMap[itemKey].attrs[itemAttr] = value
|
||||
u.log.Info().Str("itemKey", itemKey).Str("itemAttr", itemAttr).Str("value", value).Msg("overriding gadget config")
|
||||
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func mountConfigFS() error {
|
||||
_, err := os.Stat(gadgetPath)
|
||||
// TODO: check if it's mounted properly
|
||||
|
|
|
@ -55,8 +55,6 @@ var absoluteMouseCombinedReportDesc = []byte{
|
|||
0x09, 0x38, // Usage (Wheel)
|
||||
0x15, 0x81, // Logical Minimum (-127)
|
||||
0x25, 0x7F, // Logical Maximum (127)
|
||||
0x35, 0x00, // Physical Minimum (0) = Reset Physical Minimum
|
||||
0x45, 0x00, // Physical Maximum (0) = Reset Physical Maximum
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x81, 0x06, // Input (Data, Var, Rel)
|
||||
|
|
|
@ -14,13 +14,10 @@ var massStorageLun0Config = gadgetConfigItem{
|
|||
order: 3001,
|
||||
path: []string{"functions", "mass_storage.usb0", "lun.0"},
|
||||
attrs: gadgetAttributes{
|
||||
"cdrom": "1",
|
||||
"ro": "1",
|
||||
"removable": "1",
|
||||
"file": "\n",
|
||||
// the additional whitespace is intentional to avoid the "JetKVM V irtual Media" string
|
||||
// https://github.com/jetkvm/rv1106-system/blob/778133a1c153041e73f7de86c9c434a2753ea65d/sysdrv/source/uboot/u-boot/drivers/usb/gadget/f_mass_storage.c#L2556
|
||||
// Vendor (8 chars), product (16 chars)
|
||||
"inquiry_string": "JetKVM Virtual Media",
|
||||
"cdrom": "1",
|
||||
"ro": "1",
|
||||
"removable": "1",
|
||||
"file": "\n",
|
||||
"inquiry_string": "JetKVM Virtual Media",
|
||||
},
|
||||
}
|
||||
|
|
33
jsonrpc.go
33
jsonrpc.go
|
@ -38,10 +38,6 @@ type JSONRPCEvent struct {
|
|||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type DisplayRotationSettings struct {
|
||||
Rotation string `json:"rotation"`
|
||||
}
|
||||
|
||||
type BacklightSettings struct {
|
||||
MaxBrightness int `json:"max_brightness"`
|
||||
DimAfter int `json:"dim_after"`
|
||||
|
@ -284,24 +280,6 @@ func rpcTryUpdate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
||||
var err error
|
||||
_, err = lvDispSetRotation(params.Rotation)
|
||||
if err == nil {
|
||||
config.DisplayRotation = params.Rotation
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func rpcGetDisplayRotation() (*DisplayRotationSettings, error) {
|
||||
return &DisplayRotationSettings{
|
||||
Rotation: config.DisplayRotation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rpcSetBacklightSettings(params BacklightSettings) error {
|
||||
blConfig := params
|
||||
|
||||
|
@ -566,12 +544,9 @@ type RPCHandler struct {
|
|||
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
|
||||
var cdrom bool
|
||||
switch mode {
|
||||
case "cdrom":
|
||||
if mode == "cdrom" {
|
||||
cdrom = true
|
||||
case "file":
|
||||
cdrom = false
|
||||
default:
|
||||
} else if mode != "file" {
|
||||
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
|
||||
return "", fmt.Errorf("invalid mode: %s", mode)
|
||||
}
|
||||
|
@ -590,7 +565,7 @@ func rpcSetMassStorageMode(mode string) (string, error) {
|
|||
}
|
||||
|
||||
func rpcGetMassStorageMode() (string, error) {
|
||||
cdrom, err := getMassStorageCDROMEnabled()
|
||||
cdrom, err := getMassStorageMode()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get mass storage mode: %w", err)
|
||||
}
|
||||
|
@ -1049,8 +1024,6 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||
|
|
8
main.go
8
main.go
|
@ -77,14 +77,6 @@ func Main() {
|
|||
|
||||
initUsbGadget()
|
||||
|
||||
if err := setInitialVirtualMediaState(); err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
|
||||
}
|
||||
|
||||
if err := initImagesFolder(); err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to init images folder")
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(15 * time.Minute)
|
||||
for {
|
||||
|
|
Binary file not shown.
|
@ -1 +1 @@
|
|||
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521
|
||||
4b925c7aa73d2e35a227833e806658cb17e1d25900611f93ed70b11ac9f1716d
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/stylistic",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:import/recommended",
|
||||
"prettier",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: ["./tsconfig.json", "./tsconfig.node.json"],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
/**
|
||||
* @description
|
||||
*
|
||||
* This keeps imports separate from one another, ensuring that imports are separated
|
||||
* by their relative groups. As you move through the groups, imports become closer
|
||||
* to the current file.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* import fs from 'fs';
|
||||
*
|
||||
* import package from 'npm-package';
|
||||
*
|
||||
* import xyz from '~/project-file';
|
||||
*
|
||||
* import index from '../';
|
||||
*
|
||||
* import sibling from './foo';
|
||||
* ```
|
||||
*/
|
||||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||
"newlines-between": "always",
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
alias: {
|
||||
map: [
|
||||
["@components", "./src/components"],
|
||||
["@routes", "./src/routes"],
|
||||
["@assets", "./src/assets"],
|
||||
["@", "./src"],
|
||||
],
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,93 +0,0 @@
|
|||
const {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} = require("eslint/config");
|
||||
|
||||
const globals = require("globals");
|
||||
|
||||
const {
|
||||
fixupConfigRules,
|
||||
} = require("@eslint/compat");
|
||||
|
||||
const tsParser = require("@typescript-eslint/parser");
|
||||
const reactRefresh = require("eslint-plugin-react-refresh");
|
||||
const js = require("@eslint/js");
|
||||
|
||||
const {
|
||||
FlatCompat,
|
||||
} = require("@eslint/eslintrc");
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
module.exports = defineConfig([{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json", "./tsconfig.node.json"],
|
||||
tsconfigRootDir: __dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
extends: fixupConfigRules(compat.extends(
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/stylistic",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:import/recommended",
|
||||
"prettier",
|
||||
)),
|
||||
|
||||
plugins: {
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
|
||||
rules: {
|
||||
"react-refresh/only-export-components": ["warn", {
|
||||
allowConstantExport: true,
|
||||
}],
|
||||
|
||||
"import/order": ["error", {
|
||||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||
"newlines-between": "always",
|
||||
}],
|
||||
},
|
||||
|
||||
settings: {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
alias: {
|
||||
map: [
|
||||
["@components", "./src/components"],
|
||||
["@routes", "./src/routes"],
|
||||
["@assets", "./src/assets"],
|
||||
["@", "./src"],
|
||||
],
|
||||
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, globalIgnores([
|
||||
"**/dist",
|
||||
"**/.eslintrc.cjs",
|
||||
"**/tailwind.config.js",
|
||||
"**/postcss.config.js",
|
||||
])]);
|
File diff suppressed because it is too large
Load Diff
|
@ -4,7 +4,7 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "22.15.0"
|
||||
"node": "21.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "./dev_device.sh",
|
||||
|
@ -19,67 +19,62 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.3",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@headlessui/tailwindcss": "^0.2.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cva": "^1.0.0-beta.3",
|
||||
"cva": "^1.0.0-beta.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"focus-trap-react": "^11.0.3",
|
||||
"framer-motion": "^12.11.0",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mini-svg-data-uri": "^1.4.4",
|
||||
"react": "^19.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-simple-keyboard": "^3.8.72",
|
||||
"react-simple-keyboard": "^3.7.112",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"validator": "^13.15.0",
|
||||
"react-xtermjs": "^1.0.9",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"validator": "^13.12.0",
|
||||
"xterm": "^5.3.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.6",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/validator": "^13.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/validator": "^13.12.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||
"@typescript-eslint/parser": "^8.25.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.1.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export default function AuthLayout({
|
|||
<>
|
||||
<GridBackground />
|
||||
|
||||
<div className="grid min-h-screen grid-rows-(--grid-layout)">
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar
|
||||
logoHref="/"
|
||||
actionElement={
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { JSX } from "react";
|
||||
import React from "react";
|
||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
||||
|
||||
import ExtLink from "@/components/ExtLink";
|
||||
|
@ -16,7 +16,7 @@ const sizes = {
|
|||
const themes = {
|
||||
primary: cx(
|
||||
// Base styles
|
||||
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow-sm",
|
||||
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow",
|
||||
// Hover states
|
||||
"group-hover:bg-blue-800",
|
||||
// Active states
|
||||
|
@ -24,7 +24,7 @@ const themes = {
|
|||
),
|
||||
danger: cx(
|
||||
// Base styles
|
||||
"bg-red-600 text-white border-red-700 shadow-xs shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
|
||||
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
|
||||
// Hover states
|
||||
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
|
||||
// Active states
|
||||
|
@ -34,7 +34,7 @@ const themes = {
|
|||
),
|
||||
light: cx(
|
||||
// Base styles
|
||||
"bg-white text-black border-slate-800/30 shadow-xs dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
|
||||
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
|
||||
// Hover states
|
||||
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
|
||||
// Active states
|
||||
|
@ -44,7 +44,7 @@ const themes = {
|
|||
),
|
||||
lightDanger: cx(
|
||||
// Base styles
|
||||
"bg-white text-black border-red-400/60 shadow-xs",
|
||||
"bg-white text-black border-red-400/60 shadow-sm",
|
||||
// Hover states
|
||||
"group-hover:bg-red-50/80",
|
||||
// Active states
|
||||
|
@ -56,7 +56,7 @@ const themes = {
|
|||
// Base styles
|
||||
"bg-white/0 text-black border-transparent dark:text-white",
|
||||
// Hover states
|
||||
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow-sm dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
|
||||
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
|
||||
// Active states
|
||||
"group-active:bg-slate-100/80",
|
||||
),
|
||||
|
@ -65,15 +65,15 @@ const themes = {
|
|||
const btnVariants = cva({
|
||||
base: cx(
|
||||
// Base styles
|
||||
"border rounded-sm select-none",
|
||||
"border rounded select-none",
|
||||
// Size classes
|
||||
"justify-center items-center shrink-0",
|
||||
// Transition classes
|
||||
"outline-hidden transition-all duration-200",
|
||||
"outline-none transition-all duration-200",
|
||||
// Text classes
|
||||
"font-display text-center font-medium leading-tight",
|
||||
// States
|
||||
"group-focus:outline-hidden group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
|
||||
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
|
||||
"group-disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
),
|
||||
|
||||
|
@ -175,7 +175,7 @@ type ButtonPropsType = Pick<
|
|||
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
|
||||
const classes = cx(
|
||||
"group outline-hidden",
|
||||
"group outline-none",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
loading ? "pointer-events-none" : "",
|
||||
);
|
||||
|
@ -215,7 +215,7 @@ type LinkPropsType = Pick<LinkProps, "to"> &
|
|||
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
|
||||
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
|
||||
const classes = cx(
|
||||
"group outline-hidden",
|
||||
"group outline-none",
|
||||
props.disabled ? "pointer-events-none !opacity-70" : "",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
props.loading ? "pointer-events-none" : "",
|
||||
|
@ -241,7 +241,7 @@ type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
|
|||
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
|
||||
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
|
||||
const classes = cx(
|
||||
"group outline-hidden block cursor-pointer",
|
||||
"group outline-none block cursor-pointer",
|
||||
props.disabled ? "pointer-events-none !opacity-70" : "",
|
||||
props.fullWidth ? "w-full" : "",
|
||||
props.loading ? "pointer-events-none" : "",
|
||||
|
|
|
@ -30,7 +30,7 @@ const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className },
|
|||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
"w-full rounded-sm border-none bg-white shadow-xs outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
|
||||
"w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { Ref } from "react";
|
||||
import React, { forwardRef, JSX } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
@ -15,7 +15,7 @@ const checkboxVariants = cva({
|
|||
"block rounded",
|
||||
|
||||
// Colors
|
||||
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 checked:accent-blue-700 checked:dark:accent-blue-500 transition-colors",
|
||||
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors",
|
||||
|
||||
// Hover
|
||||
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
|
||||
|
@ -24,7 +24,7 @@ const checkboxVariants = cva({
|
|||
"active:bg-slate-200 dark:active:bg-slate-700",
|
||||
|
||||
// Focus
|
||||
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-hidden focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
|
||||
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
|
||||
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:opacity-30",
|
||||
|
@ -41,9 +41,7 @@ const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
|||
ref,
|
||||
) {
|
||||
const classes = checkboxVariants({ size });
|
||||
return (
|
||||
<input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />
|
||||
);
|
||||
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
|
||||
});
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
|
|
|
@ -1,14 +1,7 @@
|
|||
import { useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Combobox as HeadlessCombobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/react";
|
||||
|
||||
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
|
||||
import { cva } from "@/cva.config";
|
||||
|
||||
import Card from "./Card";
|
||||
|
||||
export interface ComboboxOption {
|
||||
|
@ -29,7 +22,7 @@ const comboboxVariants = cva({
|
|||
|
||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
||||
|
||||
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
||||
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
|
||||
displayValue: (option: ComboboxOption) => string;
|
||||
onInputChange: (option: string) => void;
|
||||
options: () => ComboboxOption[];
|
||||
|
@ -55,68 +48,72 @@ export function Combobox({
|
|||
const classes = comboboxVariants({ size });
|
||||
|
||||
return (
|
||||
<HeadlessCombobox onChange={onChange} {...otherProps}>
|
||||
<HeadlessCombobox
|
||||
onChange={onChange}
|
||||
{...otherProps}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow-xs outline-0 dark:!border-slate-300/30">
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
className={clsx(
|
||||
classes,
|
||||
|
||||
// General styling
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||
|
||||
// Hover
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60",
|
||||
|
||||
// Dark mode
|
||||
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
|
||||
|
||||
// Focus
|
||||
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
||||
|
||||
// Disabled
|
||||
disabled &&
|
||||
"pointer-events-none select-none bg-slate-50 text-slate-500/80 disabled:hover:bg-white dark:bg-slate-800 dark:text-slate-400/80 dark:disabled:hover:bg-slate-800",
|
||||
)}
|
||||
placeholder={disabled ? disabledMessage : placeholder}
|
||||
displayValue={displayValue}
|
||||
onChange={event => onInputChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
className={clsx(
|
||||
classes,
|
||||
|
||||
// General styling
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||
|
||||
// Hover
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60",
|
||||
|
||||
// Dark mode
|
||||
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
|
||||
|
||||
// Focus
|
||||
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
||||
|
||||
// Disabled
|
||||
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
|
||||
)}
|
||||
placeholder={disabled ? disabledMessage : placeholder}
|
||||
displayValue={displayValue}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
{options().length > 0 && (
|
||||
<ComboboxOptions className="hide-scrollbar absolute left-0 z-[100] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{options().map(option => (
|
||||
<ComboboxOption
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={clsx(
|
||||
// General styling
|
||||
"cursor-default select-none px-4 py-2",
|
||||
|
||||
// Hover and active states
|
||||
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
|
||||
|
||||
// Dark mode
|
||||
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200",
|
||||
)}
|
||||
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
|
||||
{options().map((option) => (
|
||||
<ComboboxOption
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={clsx(
|
||||
// General styling
|
||||
"cursor-default select-none py-2 px-4",
|
||||
|
||||
// Hover and active states
|
||||
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
|
||||
|
||||
// Dark mode
|
||||
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
|
||||
|
||||
{options().length === 0 && inputRef.current?.value && (
|
||||
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="text-slate-500 dark:text-slate-400">{emptyMessage}</div>
|
||||
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
|
||||
<div className="text-slate-500 dark:text-slate-400">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HeadlessCombobox>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { cx } from "@/cva.config";
|
||||
import { Button } from "@/components/Button";
|
||||
import Modal from "@/components/Modal";
|
||||
|
@ -47,15 +42,12 @@ const variantConfig = {
|
|||
iconBgClass: "bg-blue-100",
|
||||
buttonTheme: "primary",
|
||||
},
|
||||
} as Record<
|
||||
Variant,
|
||||
{
|
||||
} as Record<Variant, {
|
||||
icon: React.ElementType;
|
||||
iconClass: string;
|
||||
iconBgClass: string;
|
||||
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
|
@ -73,18 +65,13 @@ export function ConfirmDialog({
|
|||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
||||
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
||||
<div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div
|
||||
className={cx(
|
||||
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
|
||||
iconBgClass,
|
||||
)}
|
||||
>
|
||||
<div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
|
||||
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
|
@ -96,7 +83,12 @@ export function ConfirmDialog({
|
|||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{cancelText && (
|
||||
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text={cancelText}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
|
@ -111,4 +103,4 @@ export function ConfirmDialog({
|
|||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@ export default function EmptyCard({
|
|||
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
||||
<div className="space-y-2">
|
||||
{IconElm && (
|
||||
<IconElm className="mx-auto h-5 w-5 text-blue-600 dark:text-blue-600" />
|
||||
<IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
<h4 className="text-base font-bold leading-none text-black dark:text-white">
|
||||
{headline}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
export default function GridBackground() {
|
||||
return (
|
||||
<div className="absolute isolate h-screen w-screen overflow-hidden opacity-60">
|
||||
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60">
|
||||
<svg
|
||||
className="absolute inset-x-0 top-0 -z-10 h-full w-full mask-radial-[32rem_32rem] mask-radial-from-white mask-radial-to-transparent mask-radial-at-center stroke-gray-300 dark:stroke-slate-300/20"
|
||||
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { useCallback } from "react";
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { Menu, MenuButton } from "@headlessui/react";
|
||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
|
||||
import Container from "@/components/Container";
|
||||
import Card from "@/components/Card";
|
||||
import { cx } from "@/cva.config";
|
||||
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
|
@ -16,7 +17,7 @@ import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
|||
import api from "../api";
|
||||
import { isOnDevice } from "../main";
|
||||
|
||||
import { LinkButton } from "./Button";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
|
||||
interface NavbarProps {
|
||||
isLoggedIn: boolean;
|
||||
|
@ -50,12 +51,8 @@ export default function DashboardNavbar({
|
|||
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
|
||||
// for testing
|
||||
//userEmail = "user@example.org";
|
||||
//picture = "https://placehold.co/32x32"
|
||||
|
||||
return (
|
||||
<div className="w-full border-b border-b-slate-800/20 bg-white select-none dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<Container>
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<div className="flex shrink-0 items-center gap-x-8">
|
||||
|
@ -81,82 +78,86 @@ export default function DashboardNavbar({
|
|||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-x-2">
|
||||
<div className="flex shrink-0 items-center space-x-4">
|
||||
<div className="hidden items-stretch gap-x-2 md:flex">
|
||||
{showConnectionStatus && (
|
||||
<>
|
||||
<div className="w-[159px]">
|
||||
<PeerConnectionStatusCard
|
||||
state={peerConnectionState}
|
||||
title={kvmName}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<USBStateStatus
|
||||
state={usbState}
|
||||
{showConnectionStatus && (
|
||||
<div className="hidden items-center gap-x-2 md:flex">
|
||||
<div className="w-[159px]">
|
||||
<PeerConnectionStatusCard
|
||||
state={peerConnectionState}
|
||||
title={kvmName}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<USBStateStatus
|
||||
state={usbState}
|
||||
peerConnectionState={peerConnectionState}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<hr className="h-[20px] w-[1px] self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
|
||||
<div className="relative inline-block text-left">
|
||||
<Menu>
|
||||
<MenuButton className="h-full">
|
||||
<Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
|
||||
{picture ? (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" />
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton as={Fragment}>
|
||||
<Button
|
||||
theme="blank"
|
||||
size="SM"
|
||||
text={
|
||||
<>
|
||||
{picture ? <></> : userEmail}
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
|
||||
</>
|
||||
}
|
||||
LeadingIcon={({ className }) =>
|
||||
picture && (
|
||||
<img
|
||||
src={picture}
|
||||
alt="Avatar"
|
||||
className="size-6 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700"
|
||||
className={cx(
|
||||
className,
|
||||
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
|
||||
)}
|
||||
/>
|
||||
) : userEmail ? (
|
||||
<span className="font-display max-w-[200px] truncate text-sm/6 font-semibold">
|
||||
{userEmail}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-slate-900 dark:text-white" />
|
||||
</Button>
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
anchor="bottom end"
|
||||
className="right-0 mt-1 w-56 origin-top-right p-px focus:outline-hidden data-closed:opacity-0"
|
||||
>
|
||||
<MenuItem>
|
||||
<Card className="overflow-hidden">
|
||||
{userEmail && (
|
||||
<div className="space-y-1 p-1 dark:text-white">
|
||||
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
|
||||
<div className="p-2">
|
||||
<div className="font-display text-xs">
|
||||
Logged in as
|
||||
</div>
|
||||
<div className="font-display max-w-[200px] truncate text-sm font-semibold">
|
||||
{userEmail}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="space-y-1 p-1 dark:text-white">
|
||||
{userEmail && (
|
||||
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
|
||||
<Menu.Item>
|
||||
<div className="p-2">
|
||||
<div className="font-display text-xs">Logged in as</div>
|
||||
<div className="w-[200px] truncate font-display text-sm font-semibold">
|
||||
{userEmail}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-1 p-1 dark:text-white"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<button className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<ArrowLeftEndOnRectangleIcon className="size-4" />
|
||||
<div className="font-display">Log out</div>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Menu.Item>
|
||||
<div onClick={onLogout}>
|
||||
<button className="block w-full">
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
|
||||
<div className="font-display">Log out</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { Ref } from "react";
|
||||
import React, { forwardRef, JSX } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
@ -44,7 +44,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
|
|||
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
|
||||
|
||||
// Focus Within
|
||||
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-hidden focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
|
||||
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
|
||||
|
||||
// Disabled Within
|
||||
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",
|
||||
|
|
|
@ -113,7 +113,7 @@ export default function KvmCard({
|
|||
transition
|
||||
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
>
|
||||
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black/50 focus:outline-hidden">
|
||||
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
|
||||
<MenuItem>
|
||||
<div>
|
||||
|
|
|
@ -7,7 +7,7 @@ export default function LoadingSpinner({
|
|||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(className, "shrink-0 animate-spin p-[2px]")}
|
||||
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
|
||||
import { KeySequence } from "@/hooks/stores";
|
||||
|
@ -6,23 +7,16 @@ import { Button } from "@/components/Button";
|
|||
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||
import Fieldset from "@/components/Fieldset";
|
||||
import { MacroStepCard } from "@/components/MacroStepCard";
|
||||
import {
|
||||
DEFAULT_DELAY,
|
||||
MAX_STEPS_PER_MACRO,
|
||||
MAX_KEYS_PER_STEP,
|
||||
} from "@/constants/macros";
|
||||
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
||||
interface ValidationErrors {
|
||||
name?: string;
|
||||
steps?: Record<
|
||||
number,
|
||||
{
|
||||
keys?: string;
|
||||
modifiers?: string;
|
||||
delay?: string;
|
||||
}
|
||||
>;
|
||||
steps?: Record<number, {
|
||||
keys?: string;
|
||||
modifiers?: string;
|
||||
delay?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MacroFormProps {
|
||||
|
@ -59,18 +53,16 @@ export function MacroForm({
|
|||
} else if (macro.name.trim().length > 50) {
|
||||
newErrors.name = "Name must be less than 50 characters";
|
||||
}
|
||||
|
||||
|
||||
if (!macro.steps?.length) {
|
||||
newErrors.steps = { 0: { keys: "At least one step is required" } };
|
||||
} else {
|
||||
const hasKeyOrModifier = macro.steps.some(
|
||||
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
|
||||
const hasKeyOrModifier = macro.steps.some(step =>
|
||||
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
|
||||
);
|
||||
|
||||
if (!hasKeyOrModifier) {
|
||||
newErrors.steps = {
|
||||
0: { keys: "At least one step must have keys or modifiers" },
|
||||
};
|
||||
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,10 +87,7 @@ export function MacroForm({
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeySelect = (
|
||||
stepIndex: number,
|
||||
option: { value: string | null; keys?: string[] },
|
||||
) => {
|
||||
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
if (!newSteps[stepIndex]) return;
|
||||
|
||||
|
@ -108,9 +97,7 @@ export function MacroForm({
|
|||
if (!newSteps[stepIndex].keys) {
|
||||
newSteps[stepIndex].keys = [];
|
||||
}
|
||||
const keysArray = Array.isArray(newSteps[stepIndex].keys)
|
||||
? newSteps[stepIndex].keys
|
||||
: [];
|
||||
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
|
||||
if (keysArray.length >= MAX_KEYS_PER_STEP) {
|
||||
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
|
||||
return;
|
||||
|
@ -118,7 +105,7 @@ export function MacroForm({
|
|||
newSteps[stepIndex].keys = [...keysArray, option.value];
|
||||
}
|
||||
setMacro({ ...macro, steps: newSteps });
|
||||
|
||||
|
||||
if (errors.steps?.[stepIndex]?.keys) {
|
||||
const newErrors = { ...errors };
|
||||
delete newErrors.steps?.[stepIndex].keys;
|
||||
|
@ -140,7 +127,7 @@ export function MacroForm({
|
|||
const newSteps = [...(macro.steps || [])];
|
||||
newSteps[stepIndex].modifiers = modifiers;
|
||||
setMacro({ ...macro, steps: newSteps });
|
||||
|
||||
|
||||
// Clear step errors when modifiers are added
|
||||
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
|
||||
const newErrors = { ...errors };
|
||||
|
@ -161,9 +148,9 @@ export function MacroForm({
|
|||
setMacro({ ...macro, steps: newSteps });
|
||||
};
|
||||
|
||||
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
|
||||
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
|
||||
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
|
||||
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
|
||||
setMacro({ ...macro, steps: newSteps });
|
||||
};
|
||||
|
@ -194,10 +181,7 @@ export function MacroForm({
|
|||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<FieldLabel
|
||||
label="Steps"
|
||||
description={`Keys/modifiers executed in sequence with a delay between each step.`}
|
||||
/>
|
||||
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
|
||||
</div>
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
|
||||
|
@ -215,24 +199,18 @@ export function MacroForm({
|
|||
key={stepIndex}
|
||||
step={step}
|
||||
stepIndex={stepIndex}
|
||||
onDelete={
|
||||
macro.steps && macro.steps.length > 1
|
||||
? () => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
newSteps.splice(stepIndex, 1);
|
||||
setMacro(prev => ({ ...prev, steps: newSteps }));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMoveUp={() => handleStepMove(stepIndex, "up")}
|
||||
onMoveDown={() => handleStepMove(stepIndex, "down")}
|
||||
onKeySelect={option => handleKeySelect(stepIndex, option)}
|
||||
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
|
||||
keyQuery={keyQueries[stepIndex] || ""}
|
||||
onModifierChange={modifiers =>
|
||||
handleModifierChange(stepIndex, modifiers)
|
||||
}
|
||||
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
||||
onDelete={macro.steps && macro.steps.length > 1 ? () => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
newSteps.splice(stepIndex, 1);
|
||||
setMacro(prev => ({ ...prev, steps: newSteps }));
|
||||
} : undefined}
|
||||
onMoveUp={() => handleStepMove(stepIndex, 'up')}
|
||||
onMoveDown={() => handleStepMove(stepIndex, 'down')}
|
||||
onKeySelect={(option) => handleKeySelect(stepIndex, option)}
|
||||
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
|
||||
keyQuery={keyQueries[stepIndex] || ''}
|
||||
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
|
||||
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
|
||||
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
||||
/>
|
||||
))}
|
||||
|
@ -245,20 +223,18 @@ export function MacroForm({
|
|||
theme="light"
|
||||
fullWidth
|
||||
LeadingIcon={LuPlus}
|
||||
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`}
|
||||
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
|
||||
onClick={() => {
|
||||
if (isMaxStepsReached) {
|
||||
showTemporaryError(
|
||||
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
|
||||
);
|
||||
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setMacro(prev => ({
|
||||
...prev,
|
||||
steps: [
|
||||
...(prev.steps || []),
|
||||
{ keys: [], modifiers: [], delay: DEFAULT_DELAY },
|
||||
...(prev.steps || []),
|
||||
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }
|
||||
],
|
||||
}));
|
||||
setErrors({});
|
||||
|
@ -281,10 +257,15 @@ export function MacroForm({
|
|||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Cancel"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import React, { JSX } from "react";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
@ -63,7 +63,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
|||
)}
|
||||
>
|
||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow-xs outline-0 dark:!border-slate-300/30">
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||
<select
|
||||
ref={ref}
|
||||
name={name}
|
||||
|
@ -72,7 +72,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
|||
classes,
|
||||
|
||||
// General styling
|
||||
"block w-full cursor-pointer rounded-sm border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||
|
||||
// Hover
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
|
||||
|
|
|
@ -44,7 +44,7 @@ export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props)
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-xs dark:border-blue-300",
|
||||
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300",
|
||||
textStyle,
|
||||
)}
|
||||
key={`${i}-${currStepIdx}`}
|
||||
|
|
|
@ -79,11 +79,10 @@ function Terminal({
|
|||
return () => {
|
||||
setDisableKeyboardFocusTrap(false);
|
||||
};
|
||||
}, [ref, instance, enableTerminal, setDisableKeyboardFocusTrap, type]);
|
||||
}, [enableTerminal, instance, ref, setDisableKeyboardFocusTrap, type]);
|
||||
|
||||
const readyState = dataChannel.readyState;
|
||||
useEffect(() => {
|
||||
if (!instance) return;
|
||||
if (readyState !== "open") return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
@ -94,10 +93,11 @@ function Terminal({
|
|||
// Handle binary data differently based on browser implementation
|
||||
// Firefox sends data as blobs, chrome sends data as arraybuffer
|
||||
if (binaryType === "arraybuffer") {
|
||||
instance.write(new Uint8Array(e.data));
|
||||
instance?.write(new Uint8Array(e.data));
|
||||
} else if (binaryType === "blob") {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (!instance) return;
|
||||
if (!reader.result) return;
|
||||
instance.write(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
|
@ -107,12 +107,12 @@ function Terminal({
|
|||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
const onDataHandler = instance.onData(data => {
|
||||
const onDataHandler = instance?.onData(data => {
|
||||
dataChannel.send(data);
|
||||
});
|
||||
|
||||
// Setup escape key handler
|
||||
const onKeyHandler = instance.onKey(e => {
|
||||
const onKeyHandler = instance?.onKey(e => {
|
||||
const { domEvent } = e;
|
||||
if (domEvent.key === "Escape") {
|
||||
setTerminalType("none");
|
||||
|
@ -123,32 +123,32 @@ function Terminal({
|
|||
|
||||
// Send initial terminal size
|
||||
if (dataChannel.readyState === "open") {
|
||||
dataChannel.send(JSON.stringify({ rows: instance.rows, cols: instance.cols }));
|
||||
dataChannel.send(JSON.stringify({ rows: instance?.rows, cols: instance?.cols }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
onDataHandler.dispose();
|
||||
onKeyHandler.dispose();
|
||||
onDataHandler?.dispose();
|
||||
onKeyHandler?.dispose();
|
||||
};
|
||||
}, [instance, dataChannel, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
|
||||
}, [dataChannel, instance, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance) return;
|
||||
|
||||
// Load the fit addon
|
||||
const fitAddon = new FitAddon();
|
||||
instance.loadAddon(fitAddon);
|
||||
instance?.loadAddon(fitAddon);
|
||||
|
||||
instance.loadAddon(new ClipboardAddon());
|
||||
instance.loadAddon(new Unicode11Addon());
|
||||
instance.loadAddon(new WebLinksAddon());
|
||||
instance?.loadAddon(new ClipboardAddon());
|
||||
instance?.loadAddon(new Unicode11Addon());
|
||||
instance?.loadAddon(new WebLinksAddon());
|
||||
instance.unicode.activeVersion = "11";
|
||||
|
||||
if (isWebGl2Supported) {
|
||||
const webGl2Addon = new WebglAddon();
|
||||
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
|
||||
instance.loadAddon(webGl2Addon);
|
||||
instance?.loadAddon(webGl2Addon);
|
||||
}
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
|
@ -158,11 +158,13 @@ function Terminal({
|
|||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [ref, instance]);
|
||||
}, [ref, instance, dataChannel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { JSX } from "react";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
@ -17,7 +17,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|||
className={cx(
|
||||
"relative w-full",
|
||||
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
|
||||
"focus-within:border-slate-300 focus-within:outline-hidden focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
|
||||
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
|
@ -25,7 +25,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|||
{...props}
|
||||
id="asd"
|
||||
className={clsx(
|
||||
"block w-full rounded-sm border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
|
||||
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -3,18 +3,18 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
|||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { LuPlay } from "react-icons/lu";
|
||||
import { BsMouseFill } from "react-icons/bs";
|
||||
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { BsMouseFill } from "react-icons/bs";
|
||||
|
||||
interface OverlayContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
function OverlayContent({ children }: OverlayContentProps) {
|
||||
return (
|
||||
<GridCard cardClassName="h-full pointer-events-auto !outline-hidden">
|
||||
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
|
||||
{children}
|
||||
</div>
|
||||
|
@ -242,8 +242,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||
Ensure source device is powered on and outputting a signal
|
||||
</li>
|
||||
<li>
|
||||
If using an adapter, ensure it's compatible and functioning
|
||||
correctly
|
||||
If using an adapter, ensure it's compatible and
|
||||
functioning correctly
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -377,7 +377,7 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
|
|||
>
|
||||
<div>
|
||||
<Card className="rounded-b-none shadow-none !outline-0">
|
||||
<div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-xs dark:border-slate-300/20 dark:bg-slate-800">
|
||||
<div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-sm dark:border-slate-300/20 dark:bg-slate-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
<span className="text-sm text-black dark:text-white">
|
||||
|
|
|
@ -143,16 +143,6 @@ function KeyboardWrapper() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (key === "CtrlAltBackspace") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Backspace"]],
|
||||
[modifiers["ControlLeft"], modifiers["AltLeft"]],
|
||||
);
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyShift || isKeyCaps) {
|
||||
toggleLayout();
|
||||
|
||||
|
@ -267,13 +257,13 @@ function KeyboardWrapper() {
|
|||
buttonTheme={[
|
||||
{
|
||||
class: "combination-key",
|
||||
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
buttons: "CtrlAltDelete AltMetaEscape",
|
||||
},
|
||||
]}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"CtrlAltDelete AltMetaEscape",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||
|
@ -282,7 +272,7 @@ function KeyboardWrapper() {
|
|||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
shift: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"CtrlAltDelete AltMetaEscape",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||
|
@ -292,7 +282,7 @@ function KeyboardWrapper() {
|
|||
],
|
||||
}}
|
||||
disableButtonHold={true}
|
||||
syncInstanceInputs={true}
|
||||
mergeDisplay={true}
|
||||
debug={false}
|
||||
/>
|
||||
|
||||
|
@ -300,25 +290,34 @@ function KeyboardWrapper() {
|
|||
<Keyboard
|
||||
baseClass="simple-keyboard-control"
|
||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||
layoutName={layoutName}
|
||||
onKeyPress={onKeyDown}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
|
||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
|
||||
default: ["Home Pageup", "Delete End Pagedown"],
|
||||
}}
|
||||
display={{
|
||||
Home: "home",
|
||||
Pageup: "pageup",
|
||||
Delete: "delete",
|
||||
End: "end",
|
||||
Pagedown: "pagedown",
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
onKeyPress={onKeyDown}
|
||||
mergeDisplay={true}
|
||||
debug={false}
|
||||
/>
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-arrows"
|
||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||
onKeyPress={onKeyDown}
|
||||
display={keyDisplayMap}
|
||||
display={{
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
}}
|
||||
layout={{
|
||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
onKeyPress={onKeyDown}
|
||||
debug={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useResizeObserver } from "usehooks-ts";
|
||||
|
||||
import {
|
||||
useDeviceSettingsStore,
|
||||
|
@ -11,6 +10,7 @@ import {
|
|||
useVideoStore,
|
||||
} from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { useResizeObserver } from "@/hooks/useResizeObserver";
|
||||
import { cx } from "@/cva.config";
|
||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||
import Actionbar from "@components/ActionBar";
|
||||
|
@ -67,7 +67,7 @@ export default function WebRTCVideo() {
|
|||
|
||||
// Video-related
|
||||
useResizeObserver({
|
||||
ref: videoElm as React.RefObject<HTMLElement>,
|
||||
ref: videoElm,
|
||||
onResize: ({ width, height }) => {
|
||||
// This is actually client size, not videoSize
|
||||
if (width && height) {
|
||||
|
@ -151,7 +151,7 @@ export default function WebRTCVideo() {
|
|||
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
||||
if (isKeyboardLockGranted) {
|
||||
if ("keyboard" in navigator) {
|
||||
// @ts-expect-error - keyboard lock is not supported in all browsers
|
||||
// @ts-ignore
|
||||
await navigator.keyboard.lock();
|
||||
}
|
||||
}
|
||||
|
@ -330,31 +330,11 @@ export default function WebRTCVideo() {
|
|||
)
|
||||
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
|
||||
// Example: If altKey is true, keep all modifiers
|
||||
// If altKey is false, filter out 0x04 (AltLeft)
|
||||
//
|
||||
// But intentionally do not filter out 0x40 (AltRight) to accomodate
|
||||
// Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare
|
||||
// itself to be an altKey. For example, the KeyboardEvent for
|
||||
// Alt Gr + 2 has the following structure:
|
||||
// - altKey: false
|
||||
// - code: "Digit2"
|
||||
// - type: [ "keydown" | "keyup" ]
|
||||
//
|
||||
// For context, filteredModifiers aims to keep track which modifiers
|
||||
// are being pressed on the physical keyboard at any point in time.
|
||||
// There is logic in the keyUpHandler and keyDownHandler to add and
|
||||
// remove 0x40 (AltRight) from the list of new modifiers.
|
||||
//
|
||||
// But relying on the two handlers alone to track the state of the
|
||||
// modifier bears the risk that the key up event for Alt Gr could
|
||||
// get lost while the browser window is temporarily out of focus,
|
||||
// which means the Alt Gr key state would then be "stuck". At this
|
||||
// point, we would need to rely on the user to press Alt Gr again
|
||||
// to properly release the state of that modifier.
|
||||
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
altKey ||
|
||||
(modifier !== modifiers["AltLeft"]),
|
||||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
|
||||
)
|
||||
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
|
||||
// Example: If metaKey is true, keep all modifiers
|
||||
|
@ -673,7 +653,7 @@ export default function WebRTCVideo() {
|
|||
]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-(--grid-layout)">
|
||||
<div className="grid h-full w-full grid-rows-layout">
|
||||
<div className="flex min-h-[39.5px] flex-col">
|
||||
<div className="flex flex-col">
|
||||
<fieldset
|
||||
|
@ -699,7 +679,7 @@ export default function WebRTCVideo() {
|
|||
<div className="flex h-full flex-col">
|
||||
<div className="relative flex-grow overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="grid flex-grow grid-rows-(--grid-bodyFooter) overflow-hidden">
|
||||
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden">
|
||||
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="relative inline-block">
|
||||
|
@ -724,7 +704,7 @@ export default function WebRTCVideo() {
|
|||
hdmiError ||
|
||||
peerConnectionState !== "connected",
|
||||
"!opacity-60": showPointerLockBar,
|
||||
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
|
||||
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
||||
isPlaying,
|
||||
},
|
||||
)}
|
||||
|
@ -732,7 +712,7 @@ export default function WebRTCVideo() {
|
|||
{peerConnection?.connectionState == "connected" && (
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center"
|
||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
||||
>
|
||||
<div className="relative h-full w-full rounded-md">
|
||||
<LoadingVideoOverlay show={isVideoLoading} />
|
||||
|
|
|
@ -107,7 +107,7 @@ export function ATXPowerControl() {
|
|||
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-[120px] animate-fadeIn">
|
||||
<Card className="h-[120px] animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
@ -63,7 +63,7 @@ export function DCPowerControl() {
|
|||
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-[160px] animate-fadeIn">
|
||||
<Card className="h-[160px] animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Power Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
@ -58,7 +58,7 @@ export function SerialConsole() {
|
|||
description="Configure your serial console settings"
|
||||
/>
|
||||
|
||||
<Card className="animate-fadeIn">
|
||||
<Card className="animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Open Console Button */}
|
||||
<div className="flex items-center">
|
||||
|
|
|
@ -84,7 +84,7 @@ export default function ExtensionPopover() {
|
|||
return (
|
||||
<GridCard>
|
||||
<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-headerBody">
|
||||
<div className="space-y-4">
|
||||
{activeExtension ? (
|
||||
// Extension Control View
|
||||
|
@ -92,7 +92,7 @@ export default function ExtensionPopover() {
|
|||
{renderActiveExtension()}
|
||||
|
||||
<div
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
@ -113,7 +113,7 @@ export default function ExtensionPopover() {
|
|||
title="Extensions"
|
||||
description="Load and manage your extensions"
|
||||
/>
|
||||
<Card className="animate-fadeIn">
|
||||
<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
|
||||
|
|
|
@ -194,7 +194,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
return (
|
||||
<GridCard>
|
||||
<div className="space-y-4 p-4 py-3">
|
||||
<div ref={ref} className="grid h-full grid-rows-(--grid-headerBody)">
|
||||
<div ref={ref} className="grid h-full grid-rows-headerBody">
|
||||
<div className="h-full space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
|
@ -214,7 +214,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
) : null}
|
||||
|
||||
<div
|
||||
className="animate-fadeIn space-y-2"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -289,7 +289,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
|
||||
{!remoteVirtualMediaState && (
|
||||
<div
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
|
|
@ -106,7 +106,7 @@ export default function PasteModal() {
|
|||
return (
|
||||
<GridCard>
|
||||
<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-headerBody">
|
||||
<div className="h-full space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
|
@ -115,7 +115,7 @@ export default function PasteModal() {
|
|||
/>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn space-y-2"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -174,7 +174,7 @@ export default function PasteModal() {
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex animate-fadeIn items-center justify-end gap-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
|
|
@ -26,7 +26,7 @@ export default function AddDeviceForm({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="animate-fadeIn space-y-4"
|
||||
className="animate-fadeIn space-y-4 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.5s",
|
||||
animationFillMode: "forwards",
|
||||
|
@ -73,7 +73,7 @@ export default function AddDeviceForm({
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
|
|
@ -28,7 +28,7 @@ export default function DeviceList({
|
|||
}: DeviceListProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="animate-fadeIn">
|
||||
<Card className="animate-fadeIn opacity-0">
|
||||
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
|
||||
{storedDevices.map((device, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-x-2 p-3">
|
||||
|
@ -63,7 +63,7 @@ export default function DeviceList({
|
|||
</div>
|
||||
</Card>
|
||||
<div
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
|
|
@ -13,7 +13,7 @@ export default function EmptyStateCard({
|
|||
}) {
|
||||
return (
|
||||
<div className="select-none space-y-4">
|
||||
<Card className="animate-fadeIn">
|
||||
<Card className="animate-fadeIn opacity-0">
|
||||
<div className="flex items-center justify-center py-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
|
@ -35,7 +35,7 @@ export default function EmptyStateCard({
|
|||
</div>
|
||||
</Card>
|
||||
<div
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2"
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
|
|
@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
|
|||
return (
|
||||
<GridCard>
|
||||
<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-headerBody">
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Wake On LAN"
|
||||
|
|
|
@ -99,7 +99,7 @@ export default function ConnectionStatsSidebar() {
|
|||
}, 500);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
||||
<div className="grid h-full grid-rows-headerBody shadow-sm">
|
||||
<SidebarHeader title="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="space-y-4">
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import {
|
||||
MAX_STEPS_PER_MACRO,
|
||||
MAX_TOTAL_MACROS,
|
||||
MAX_KEYS_PER_STEP,
|
||||
} from "@/constants/macros";
|
||||
import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
|
||||
|
||||
// Define the JsonRpc types for better type checking
|
||||
interface JsonRpcResponse {
|
||||
|
@ -297,9 +292,6 @@ interface SettingsState {
|
|||
developerMode: boolean;
|
||||
setDeveloperMode: (enabled: boolean) => void;
|
||||
|
||||
displayRotation: string;
|
||||
setDisplayRotation: (rotation: string) => void;
|
||||
|
||||
backlightSettings: BacklightSettings;
|
||||
setBacklightSettings: (settings: BacklightSettings) => void;
|
||||
}
|
||||
|
@ -320,10 +312,6 @@ export const useSettingsStore = create(
|
|||
developerMode: false,
|
||||
setDeveloperMode: enabled => set({ developerMode: enabled }),
|
||||
|
||||
displayRotation: "270",
|
||||
setDisplayRotation: (rotation: string) =>
|
||||
set({ displayRotation: rotation }),
|
||||
|
||||
backlightSettings: {
|
||||
max_brightness: 100,
|
||||
dim_after: 10000,
|
||||
|
@ -581,12 +569,12 @@ export interface UpdateState {
|
|||
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||
modalView:
|
||||
| "loading"
|
||||
| "updating"
|
||||
| "upToDate"
|
||||
| "updateAvailable"
|
||||
| "updateCompleted"
|
||||
| "error";
|
||||
| "loading"
|
||||
| "updating"
|
||||
| "upToDate"
|
||||
| "updateAvailable"
|
||||
| "updateCompleted"
|
||||
| "error";
|
||||
setModalView: (view: UpdateState["modalView"]) => void;
|
||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||
updateErrorMessage: string | null;
|
||||
|
@ -650,12 +638,12 @@ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
|
|||
|
||||
interface LocalAuthModalState {
|
||||
modalView:
|
||||
| "createPassword"
|
||||
| "deletePassword"
|
||||
| "updatePassword"
|
||||
| "creationSuccess"
|
||||
| "deleteSuccess"
|
||||
| "updateSuccess";
|
||||
| "createPassword"
|
||||
| "deletePassword"
|
||||
| "updatePassword"
|
||||
| "creationSuccess"
|
||||
| "deleteSuccess"
|
||||
| "updateSuccess";
|
||||
setModalView: (view: LocalAuthModalState["modalView"]) => void;
|
||||
}
|
||||
|
||||
|
@ -736,23 +724,12 @@ export interface NetworkState {
|
|||
setDhcpLeaseExpiry: (expiry: Date) => void;
|
||||
}
|
||||
|
||||
export type IPv6Mode =
|
||||
| "disabled"
|
||||
| "slaac"
|
||||
| "dhcpv6"
|
||||
| "slaac_and_dhcpv6"
|
||||
| "static"
|
||||
| "link_local"
|
||||
| "unknown";
|
||||
|
||||
export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown";
|
||||
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
|
||||
export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
|
||||
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
|
||||
export type TimeSyncMode =
|
||||
| "ntp_only"
|
||||
| "ntp_and_http"
|
||||
| "http_only"
|
||||
| "custom"
|
||||
| "unknown";
|
||||
export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown";
|
||||
|
||||
export interface NetworkSettings {
|
||||
hostname: string;
|
||||
|
@ -777,7 +754,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
|||
|
||||
lease.lease_expiry = expiry;
|
||||
set({ dhcp_lease: lease });
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
||||
export interface KeySequenceStep {
|
||||
|
@ -799,20 +776,8 @@ export interface MacrosState {
|
|||
initialized: boolean;
|
||||
loadMacros: () => Promise<void>;
|
||||
saveMacros: (macros: KeySequence[]) => Promise<void>;
|
||||
sendFn:
|
||||
| ((
|
||||
method: string,
|
||||
params: unknown,
|
||||
callback?: ((resp: JsonRpcResponse) => void) | undefined,
|
||||
) => void)
|
||||
| null;
|
||||
setSendFn: (
|
||||
sendFn: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
callback?: ((resp: JsonRpcResponse) => void) | undefined,
|
||||
) => void,
|
||||
) => void;
|
||||
sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null;
|
||||
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
|
||||
}
|
||||
|
||||
export const generateMacroId = () => {
|
||||
|
@ -825,7 +790,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
initialized: false,
|
||||
sendFn: null,
|
||||
|
||||
setSendFn: sendFn => {
|
||||
setSendFn: (sendFn) => {
|
||||
set({ sendFn });
|
||||
},
|
||||
|
||||
|
@ -842,7 +807,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sendFn("getKeyboardMacros", {}, response => {
|
||||
sendFn("getKeyboardMacros", {}, (response) => {
|
||||
if (response.error) {
|
||||
console.error("Error loading macros:", response.error);
|
||||
reject(new Error(response.error.message));
|
||||
|
@ -862,7 +827,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
|
||||
set({
|
||||
macros: sortedMacros,
|
||||
initialized: true,
|
||||
initialized: true
|
||||
});
|
||||
|
||||
resolve();
|
||||
|
@ -889,23 +854,15 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
|
||||
for (const macro of macros) {
|
||||
if (macro.steps.length > MAX_STEPS_PER_MACRO) {
|
||||
console.error(
|
||||
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
||||
);
|
||||
throw new Error(
|
||||
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
||||
);
|
||||
console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
|
||||
throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < macro.steps.length; i++) {
|
||||
const step = macro.steps[i];
|
||||
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
|
||||
console.error(
|
||||
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
||||
);
|
||||
throw new Error(
|
||||
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
||||
);
|
||||
console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
|
||||
throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -915,25 +872,20 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
try {
|
||||
const macrosWithSortOrder = macros.map((macro, index) => ({
|
||||
...macro,
|
||||
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index,
|
||||
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index
|
||||
}));
|
||||
|
||||
const response = await new Promise<JsonRpcResponse>(resolve => {
|
||||
sendFn(
|
||||
"setKeyboardMacros",
|
||||
{ params: { macros: macrosWithSortOrder } },
|
||||
response => {
|
||||
resolve(response);
|
||||
},
|
||||
);
|
||||
const response = await new Promise<JsonRpcResponse>((resolve) => {
|
||||
sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error saving macros:", response.error);
|
||||
const errorMessage =
|
||||
typeof response.error.data === "string"
|
||||
? response.error.data
|
||||
: response.error.message || "Failed to save macros";
|
||||
const errorMessage = typeof response.error.data === 'string'
|
||||
? response.error.data
|
||||
: response.error.message || "Failed to save macros";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
|
@ -945,6 +897,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function useInterval(callback: () => void, delay: number) {
|
||||
const savedCallback = useRef<typeof callback>();
|
||||
|
||||
// Save the callback directly in the useRef object
|
||||
savedCallback.current = callback;
|
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
if (!savedCallback.current) return;
|
||||
savedCallback.current();
|
||||
}
|
||||
|
||||
if (delay !== null) {
|
||||
const id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
}, [delay]);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that determines if the component is currently mounted.
|
||||
* @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted.
|
||||
* @public
|
||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted)
|
||||
* @example
|
||||
* ```tsx
|
||||
* const isComponentMounted = useIsMounted();
|
||||
* // Use isComponentMounted() to check if the component is currently mounted before performing certain actions.
|
||||
* ```
|
||||
*/
|
||||
export function useIsMounted(): () => boolean {
|
||||
const isMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(() => isMounted.current, []);
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { useIsMounted } from "./useIsMounted";
|
||||
|
||||
/** The size of the observed element. */
|
||||
interface Size {
|
||||
/** The width of the observed element. */
|
||||
width: number | undefined;
|
||||
/** The height of the observed element. */
|
||||
height: number | undefined;
|
||||
}
|
||||
|
||||
/** The options for the ResizeObserver. */
|
||||
interface UseResizeObserverOptions<T extends HTMLElement = HTMLElement> {
|
||||
/** The ref of the element to observe. */
|
||||
ref: RefObject<T>;
|
||||
/**
|
||||
* When using `onResize`, the hook doesn't re-render on element size changes; it delegates handling to the provided callback.
|
||||
* @default undefined
|
||||
*/
|
||||
onResize?: (size: Size) => void;
|
||||
/**
|
||||
* The box model to use for the ResizeObserver.
|
||||
* @default 'content-box'
|
||||
*/
|
||||
box?: "border-box" | "content-box" | "device-pixel-content-box";
|
||||
}
|
||||
|
||||
const initialSize: Size = {
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook that observes the size of an element using the ResizeObserver API.
|
||||
* @template T - The type of the element to observe.
|
||||
* @param {UseResizeObserverOptions<T>} options - The options for the ResizeObserver.
|
||||
* @returns {Size} - The size of the observed element.
|
||||
* @public
|
||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-resize-observer)
|
||||
* @see [MDN ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
|
||||
* @example
|
||||
* ```tsx
|
||||
* const myRef = useRef(null);
|
||||
* const { width = 0, height = 0 } = useResizeObserver({
|
||||
* ref: myRef,
|
||||
* box: 'content-box',
|
||||
* });
|
||||
*
|
||||
* <div ref={myRef}>Hello, world!</div>
|
||||
* ```
|
||||
*/
|
||||
export function useResizeObserver<T extends HTMLElement = HTMLElement>(
|
||||
options: UseResizeObserverOptions<T>,
|
||||
): Size {
|
||||
const { ref, box = "content-box" } = options;
|
||||
const [{ width, height }, setSize] = useState<Size>(initialSize);
|
||||
const isMounted = useIsMounted();
|
||||
const previousSize = useRef<Size>({ ...initialSize });
|
||||
const onResize = useRef<((size: Size) => void) | undefined>(undefined);
|
||||
onResize.current = options.onResize;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
if (typeof window === "undefined" || !("ResizeObserver" in window)) return;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const boxProp =
|
||||
box === "border-box"
|
||||
? "borderBoxSize"
|
||||
: box === "device-pixel-content-box"
|
||||
? "devicePixelContentBoxSize"
|
||||
: "contentBoxSize";
|
||||
|
||||
const newWidth = extractSize(entry, boxProp, "inlineSize");
|
||||
const newHeight = extractSize(entry, boxProp, "blockSize");
|
||||
|
||||
const hasChanged =
|
||||
previousSize.current.width !== newWidth ||
|
||||
previousSize.current.height !== newHeight;
|
||||
|
||||
if (hasChanged) {
|
||||
const newSize: Size = { width: newWidth, height: newHeight };
|
||||
previousSize.current.width = newWidth;
|
||||
previousSize.current.height = newHeight;
|
||||
|
||||
if (onResize.current) {
|
||||
onResize.current(newSize);
|
||||
} else {
|
||||
if (isMounted()) {
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(ref.current, { box });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [box, isMounted, ref]);
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/** @private */
|
||||
type BoxSizesKey = keyof Pick<
|
||||
ResizeObserverEntry,
|
||||
"borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize"
|
||||
>;
|
||||
|
||||
function extractSize(
|
||||
entry: ResizeObserverEntry,
|
||||
box: BoxSizesKey,
|
||||
sizeType: keyof ResizeObserverSize,
|
||||
): number | undefined {
|
||||
if (!entry[box]) {
|
||||
if (box === "contentBoxSize") {
|
||||
return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Array.isArray(entry[box])
|
||||
? entry[box][0][sizeType]
|
||||
: // @ts-expect-error Support Firefox's non-standard behavior
|
||||
(entry[box][sizeType] as number);
|
||||
}
|
137
ui/src/index.css
137
ui/src/index.css
|
@ -1,11 +1,6 @@
|
|||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
@plugin "@headlessui/tailwindcss";
|
||||
|
||||
/* Dark mode uses CSS selector instead of prefers-color-scheme */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
|
@ -18,128 +13,6 @@ body {
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: "Circular", sans-serif;
|
||||
--font-display: "Circular", sans-serif;
|
||||
--font-serif: "Circular", serif;
|
||||
--font-mono: "Source Code Pro Variable", monospace;
|
||||
|
||||
--grid-layout: auto 1fr auto;
|
||||
--grid-headerBody: auto 1fr;
|
||||
--grid-bodyFooter: 1fr auto;
|
||||
--grid-sidebar: 1fr minmax(360px, 25%);
|
||||
|
||||
--breakpoint-xs: 480px;
|
||||
--breakpoint-2xl: 1440px;
|
||||
--breakpoint-3xl: 1920px;
|
||||
--breakpoint-4xl: 2560px;
|
||||
|
||||
/* Migrated animations */
|
||||
--animate-enter: enter 0.2s ease-out;
|
||||
--animate-leave: leave 0.15s ease-in forwards;
|
||||
--animate-fadeInScale: fadeInScale 1s ease-out forwards;
|
||||
--animate-fadeInScaleFloat:
|
||||
fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite;
|
||||
--animate-fadeIn: fadeIn 1s ease-out forwards;
|
||||
--animate-slideUpFade: slideUpFade 1s ease-out forwards;
|
||||
|
||||
--container-8xl: 88rem;
|
||||
--container-9xl: 96rem;
|
||||
--container-10xl: 104rem;
|
||||
--container-11xl: 112rem;
|
||||
--container-12xl: 120rem;
|
||||
|
||||
/* Migrated keyframes */
|
||||
@keyframes enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leave {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScaleFloat {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.98) translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUpFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility max-width-* {
|
||||
max-width: --modifier(--container- *, [length], [ *]);
|
||||
}
|
||||
|
||||
/* Ensure there is not a `ms` and ms -> `...)ms` */
|
||||
@utility animation-delay-* {
|
||||
animation-delay: --value(integer) ms;
|
||||
}
|
||||
|
||||
@property --grid-color-start {
|
||||
syntax: "<color>";
|
||||
initial-value: theme("colors.blue.50/10");
|
||||
|
@ -177,7 +50,7 @@ video::-webkit-media-controls {
|
|||
}
|
||||
|
||||
.hg-theme-default .hg-button {
|
||||
@apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-xs;
|
||||
@apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-sm;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-button span {
|
||||
|
@ -301,7 +174,7 @@ video::-webkit-media-controls {
|
|||
}
|
||||
|
||||
.hg-theme-default .hg-row .combination-key {
|
||||
@apply inline-flex !h-auto !w-auto grow-0 py-1 text-xs;
|
||||
@apply inline-flex !h-auto !w-auto flex-grow-0 py-1 text-xs;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-row:has(.combination-key) {
|
||||
|
|
|
@ -99,15 +99,8 @@ export const chars = {
|
|||
"~": { key: "Backquote", shift: true },
|
||||
"§": { key: "IntlBackslash" },
|
||||
"±": { key: "IntlBackslash", shift: true },
|
||||
" ": { key: "Space", shift: false },
|
||||
"\n": { key: "Enter", shift: false },
|
||||
Enter: { key: "Enter", shift: false },
|
||||
Tab: { key: "Tab", shift: false },
|
||||
PrintScreen: { key: "Prt Sc", shift: false },
|
||||
SystemRequest: { key: "Prt Sc", shift: true },
|
||||
ScrollLock: { key: "ScrollLock", shift: false},
|
||||
Pause: { key: "Pause", shift: false },
|
||||
Break: { key: "Pause", shift: true },
|
||||
Insert: { key: "Insert", shift: false },
|
||||
Delete: { key: "Delete", shift: false },
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Key codes and modifiers correspond to definitions in the
|
||||
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
|
||||
export const keys = {
|
||||
AltLeft: 0xe2,
|
||||
AltRight: 0xe6,
|
||||
ArrowDown: 0x51,
|
||||
ArrowLeft: 0x50,
|
||||
ArrowRight: 0x4f,
|
||||
|
@ -86,21 +86,16 @@ export const keys = {
|
|||
NumpadAdd: 0x57,
|
||||
NumpadDivide: 0x54,
|
||||
NumpadEnter: 0x58,
|
||||
NumpadEqual: 0x67,
|
||||
NumpadMultiply: 0x55,
|
||||
NumpadSubtract: 0x56,
|
||||
NumpadDecimal: 0x63,
|
||||
PageDown: 0x4e,
|
||||
PageUp: 0x4b,
|
||||
Period: 0x37,
|
||||
PrintScreen: 0x46,
|
||||
Pause: 0x48,
|
||||
Quote: 0x34,
|
||||
ScrollLock: 0x47,
|
||||
Semicolon: 0x33,
|
||||
Slash: 0x38,
|
||||
Space: 0x2c,
|
||||
SystemRequest: 0x9a,
|
||||
Tab: 0x2b,
|
||||
} as Record<string, number>;
|
||||
|
||||
|
@ -129,11 +124,9 @@ export const modifierDisplayMap: Record<string, string> = {
|
|||
export const keyDisplayMap: Record<string, string> = {
|
||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
||||
AltMetaEscape: "Alt + Meta + Escape",
|
||||
CtrlAltBackspace: "Ctrl + Alt + Backspace",
|
||||
Escape: "esc",
|
||||
Tab: "tab",
|
||||
Backspace: "backspace",
|
||||
"(Backspace)": "backspace",
|
||||
Enter: "enter",
|
||||
CapsLock: "caps lock",
|
||||
ShiftLeft: "shift",
|
||||
|
@ -144,12 +137,11 @@ export const keyDisplayMap: Record<string, string> = {
|
|||
MetaLeft: "meta",
|
||||
MetaRight: "meta",
|
||||
Space: " ",
|
||||
Insert: "insert",
|
||||
Home: "home",
|
||||
PageUp: "page up",
|
||||
PageUp: "pageup",
|
||||
Delete: "delete",
|
||||
End: "end",
|
||||
PageDown: "page down",
|
||||
PageDown: "pagedown",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
|
@ -163,45 +155,22 @@ export const keyDisplayMap: Record<string, string> = {
|
|||
KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
|
||||
KeyZ: "z",
|
||||
|
||||
// Capital letters
|
||||
"(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E",
|
||||
"(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J",
|
||||
"(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O",
|
||||
"(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T",
|
||||
"(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y",
|
||||
"(KeyZ)": "Z",
|
||||
|
||||
// Numbers
|
||||
Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
|
||||
Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
|
||||
|
||||
// Shifted Numbers
|
||||
"(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%",
|
||||
"(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")",
|
||||
|
||||
// Symbols
|
||||
Minus: "-",
|
||||
"(Minus)": "_",
|
||||
Equal: "=",
|
||||
"(Equal)": "+",
|
||||
BracketLeft: "[",
|
||||
"(BracketLeft)": "{",
|
||||
BracketRight: "]",
|
||||
"(BracketRight)": "}",
|
||||
Backslash: "\\",
|
||||
"(Backslash)": "|",
|
||||
Semicolon: ";",
|
||||
"(Semicolon)": ":",
|
||||
Quote: "'",
|
||||
"(Quote)": "\"",
|
||||
Comma: ",",
|
||||
"(Comma)": "<",
|
||||
Period: ".",
|
||||
"(Period)": ">",
|
||||
Slash: "/",
|
||||
"(Slash)": "?",
|
||||
Backquote: "`",
|
||||
"(Backquote)": "~",
|
||||
IntlBackslash: "\\",
|
||||
|
||||
// Function keys
|
||||
|
@ -215,11 +184,5 @@ export const keyDisplayMap: Record<string, string> = {
|
|||
Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
|
||||
Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
|
||||
NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
|
||||
NumpadEqual: "Num =", NumpadEnter: "Num Enter",
|
||||
NumLock: "Num Lock",
|
||||
|
||||
// Modals
|
||||
PrintScreen: "prt sc", ScrollLock: "scr lk", Pause: "pause",
|
||||
"(PrintScreen)": "sys rq", "(Pause)": "break",
|
||||
SystemRequest: "sys rq", Break: "break"
|
||||
NumpadEnter: "Num Enter"
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ import AdoptRoute from "@routes/adopt";
|
|||
import SignupRoute from "@routes/signup";
|
||||
import LoginRoute from "@routes/login";
|
||||
import SetupRoute from "@routes/devices.$id.setup";
|
||||
import DevicesRoute from "@routes/devices";
|
||||
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
|
||||
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
|
||||
import Card from "@components/Card";
|
||||
import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
|
||||
|
@ -37,7 +37,7 @@ import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard";
|
|||
import api from "./api";
|
||||
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
|
||||
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
|
||||
import SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
|
||||
import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
|
||||
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
|
||||
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
|
||||
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
|
||||
|
@ -171,7 +171,7 @@ if (isOnDevice) {
|
|||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsAccessIndexRoute />,
|
||||
element: <SettingsAccessIndexRoute.default />,
|
||||
loader: SettingsAccessIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
|
@ -300,7 +300,7 @@ if (isOnDevice) {
|
|||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsAccessIndexRoute />,
|
||||
element: <SettingsAccessIndexRoute.default />,
|
||||
loader: SettingsAccessIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
|
@ -350,10 +350,7 @@ if (isOnDevice) {
|
|||
loader: DeviceIdRename.loader,
|
||||
action: DeviceIdRename.action,
|
||||
},
|
||||
{
|
||||
path: "devices",
|
||||
element: <DevicesRoute />,
|
||||
loader: DevicesRoute.loader },
|
||||
{ path: "devices", element: <DevicesRoute />, loader: DeviceListLoader },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
@ -368,7 +365,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
<Notifications
|
||||
toastOptions={{
|
||||
className:
|
||||
"rounded-sm border-none bg-white text-black shadow-sm outline-1 outline-slate-800/30",
|
||||
"rounded border-none bg-white text-black shadow outline outline-1 outline-slate-800/30",
|
||||
}}
|
||||
max={2}
|
||||
/>
|
||||
|
|
|
@ -71,13 +71,12 @@ export default function DevicesIdDeregister() {
|
|||
const error = useActionData() as { message: string };
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen grid-rows-(--grid-layout)">
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
kvmName={device?.name}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full">
|
||||
|
|
|
@ -320,7 +320,7 @@ function ModeSelectionView({
|
|||
].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cx("animate-fadeIn")}
|
||||
className={cx("animate-fadeIn opacity-0")}
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: `${25 * (index * 5)}ms`,
|
||||
|
@ -328,7 +328,7 @@ function ModeSelectionView({
|
|||
>
|
||||
<Card
|
||||
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] cursor-pointer bg-white shadow-sm transition-all duration-100 hover:shadow-md dark:bg-slate-800",
|
||||
{
|
||||
"ring-2 ring-blue-700": selectedMode === mode,
|
||||
"hover:ring-2 hover:ring-blue-500": selectedMode !== mode && !disabled,
|
||||
|
@ -373,7 +373,7 @@ function ModeSelectionView({
|
|||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex animate-fadeIn justify-end"
|
||||
className="flex animate-fadeIn justify-end opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
@ -414,7 +414,7 @@ function BrowserFileView({
|
|||
if (file?.name.endsWith(".iso")) {
|
||||
setUsbMode("CDROM");
|
||||
} else if (file?.name.endsWith(".img")) {
|
||||
setUsbMode("Disk");
|
||||
setUsbMode("CDROM");
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -437,7 +437,7 @@ function BrowserFileView({
|
|||
className="block cursor-pointer select-none"
|
||||
>
|
||||
<div
|
||||
className="group animate-fadeIn"
|
||||
className="group animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
|
@ -483,7 +483,7 @@ function BrowserFileView({
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-full animate-fadeIn items-end justify-between"
|
||||
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -566,7 +566,7 @@ function UrlView({
|
|||
if (url.endsWith(".iso")) {
|
||||
setUsbMode("CDROM");
|
||||
} else if (url.endsWith(".img")) {
|
||||
setUsbMode("Disk");
|
||||
setUsbMode("CDROM");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -578,7 +578,7 @@ function UrlView({
|
|||
/>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn"
|
||||
className="animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
|
@ -593,7 +593,7 @@ function UrlView({
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex w-full animate-fadeIn items-end justify-between"
|
||||
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -619,7 +619,7 @@ function UrlView({
|
|||
|
||||
<hr className="border-slate-800/30 dark:border-slate-300/20" />
|
||||
<div
|
||||
className="animate-fadeIn"
|
||||
className="animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
@ -773,7 +773,7 @@ function DeviceFileView({
|
|||
if (file.name.endsWith(".iso")) {
|
||||
setUsbMode("CDROM");
|
||||
} else if (file.name.endsWith(".img")) {
|
||||
setUsbMode("Disk");
|
||||
setUsbMode("CDROM");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -797,7 +797,7 @@ function DeviceFileView({
|
|||
description="Select an image to mount from the JetKVM storage"
|
||||
/>
|
||||
<div
|
||||
className="w-full animate-fadeIn"
|
||||
className="w-full animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -886,7 +886,7 @@ function DeviceFileView({
|
|||
|
||||
{onStorageFiles.length > 0 ? (
|
||||
<div
|
||||
className="flex animate-fadeIn items-end justify-between"
|
||||
className="flex animate-fadeIn items-end justify-between opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.15s",
|
||||
|
@ -914,7 +914,7 @@ function DeviceFileView({
|
|||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex animate-fadeIn items-end justify-end"
|
||||
className="flex animate-fadeIn items-end justify-end opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.15s",
|
||||
|
@ -927,7 +927,7 @@ function DeviceFileView({
|
|||
)}
|
||||
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
||||
<div
|
||||
className="animate-fadeIn space-y-2"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.20s",
|
||||
|
@ -941,9 +941,9 @@ function DeviceFileView({
|
|||
{percentageUsed}% used
|
||||
</span>
|
||||
</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-sm bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-xs bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
|
||||
className="h-full rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
|
||||
style={{ width: `${percentageUsed}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
@ -959,7 +959,7 @@ function DeviceFileView({
|
|||
|
||||
{onStorageFiles.length > 0 && (
|
||||
<div
|
||||
className="w-full animate-fadeIn"
|
||||
className="w-full animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.25s",
|
||||
|
@ -1251,7 +1251,7 @@ function UploadFileView({
|
|||
}
|
||||
/>
|
||||
<div
|
||||
className="animate-fadeIn space-y-2"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
|
@ -1365,7 +1365,7 @@ function UploadFileView({
|
|||
{/* Display upload error if present */}
|
||||
{uploadError && (
|
||||
<div
|
||||
className="mt-2 animate-fadeIn truncate text-sm text-red-600 dark:text-red-400"
|
||||
className="mt-2 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
>
|
||||
Error: {uploadError}
|
||||
|
@ -1373,7 +1373,7 @@ function UploadFileView({
|
|||
)}
|
||||
|
||||
<div
|
||||
className="flex w-full animate-fadeIn items-end"
|
||||
className="flex w-full animate-fadeIn items-end opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -1496,7 +1496,7 @@ function PreUploadedImageItem({
|
|||
</div>
|
||||
<div className="relative flex select-none items-center gap-x-3">
|
||||
<div
|
||||
className={cx("opacity-0 transition-opacity duration-200", {
|
||||
className={cx("opacity-0 transition-opacity duration-200", {
|
||||
"w-auto opacity-100": isHovering,
|
||||
})}
|
||||
>
|
||||
|
@ -1579,6 +1579,7 @@ function UsbModeSelector({
|
|||
type="radio"
|
||||
id="disk"
|
||||
name="mountType"
|
||||
disabled
|
||||
checked={usbMode === "Disk"}
|
||||
onChange={() => setUsbMode("Disk")}
|
||||
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
||||
|
@ -1587,6 +1588,9 @@ function UsbModeSelector({
|
|||
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
|
||||
Disk
|
||||
</span>
|
||||
<div className="text-[10px] text-slate-500 dark:text-slate-400">
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -75,13 +75,12 @@ export default function DeviceIdRename() {
|
|||
const error = useActionData() as { message: string };
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen grid-rows-(--grid-layout)">
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
kvmName={device?.name}
|
||||
/>
|
||||
|
||||
<div className="h-full w-full">
|
||||
|
|
|
@ -26,7 +26,7 @@ export interface TLSState {
|
|||
privateKey?: string;
|
||||
}
|
||||
|
||||
const loader = async () => {
|
||||
export const loader = async () => {
|
||||
if (isOnDevice) {
|
||||
const status = await api
|
||||
.GET(`${DEVICE_API}/device`)
|
||||
|
@ -468,5 +468,3 @@ export default function SettingsAccessIndexRoute() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SettingsAccessIndexRoute.loader = loader;
|
|
@ -15,25 +15,6 @@ export default function SettingsHardwareRoute() {
|
|||
const [send] = useJsonRpc();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation);
|
||||
|
||||
const handleDisplayRotationChange = (rotation: string) => {
|
||||
setDisplayRotation(rotation);
|
||||
handleDisplayRotationSave();
|
||||
};
|
||||
|
||||
const handleDisplayRotationSave = () => {
|
||||
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Display orientation updated successfully");
|
||||
});
|
||||
};
|
||||
|
||||
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
|
||||
|
||||
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
|
||||
|
@ -78,24 +59,6 @@ export default function SettingsHardwareRoute() {
|
|||
description="Configure display settings and hardware options for your JetKVM device"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Display Orientation"
|
||||
description="Set the orientation of the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.displayRotation.toString()}
|
||||
options={[
|
||||
{ value: "270", label: "Normal" },
|
||||
{ value: "90", label: "Inverted" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.displayRotation = e.target.value;
|
||||
handleDisplayRotationChange(settings.displayRotation);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Display Brightness"
|
||||
description="Set the brightness of the display"
|
||||
|
|
|
@ -1,15 +1,6 @@
|
|||
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
LuPenLine,
|
||||
LuCopy,
|
||||
LuMoveRight,
|
||||
LuCornerDownRight,
|
||||
LuArrowUp,
|
||||
LuArrowDown,
|
||||
LuTrash2,
|
||||
LuCommand,
|
||||
} from "react-icons/lu";
|
||||
import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
|
||||
|
||||
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||
|
@ -35,10 +26,10 @@ export default function SettingsMacrosRoute() {
|
|||
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
||||
|
||||
const isMaxMacrosReached = useMemo(
|
||||
() => macros.length >= MAX_TOTAL_MACROS,
|
||||
[macros.length],
|
||||
|
||||
const isMaxMacrosReached = useMemo(() =>
|
||||
macros.length >= MAX_TOTAL_MACROS,
|
||||
[macros.length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -47,83 +38,75 @@ export default function SettingsMacrosRoute() {
|
|||
}
|
||||
}, [initialized, loadMacros]);
|
||||
|
||||
const handleDuplicateMacro = useCallback(
|
||||
async (macro: KeySequence) => {
|
||||
if (!macro?.id || !macro?.name) {
|
||||
notifications.error("Invalid macro data");
|
||||
return;
|
||||
const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
|
||||
if (!macro?.id || !macro?.name) {
|
||||
notifications.error("Invalid macro data");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMaxMacrosReached) {
|
||||
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoadingId(macro.id);
|
||||
|
||||
const newMacroCopy: KeySequence = {
|
||||
...JSON.parse(JSON.stringify(macro)),
|
||||
id: generateMacroId(),
|
||||
name: `${macro.name} ${COPY_SUFFIX}`,
|
||||
sortOrder: macros.length + 1,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
||||
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to duplicate macro");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
|
||||
|
||||
if (isMaxMacrosReached) {
|
||||
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
||||
return;
|
||||
const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
|
||||
if (!Array.isArray(macros) || macros.length === 0) {
|
||||
notifications.error("No macros available");
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= macros.length) return;
|
||||
|
||||
setActionLoadingId(macroId);
|
||||
|
||||
try {
|
||||
const newMacros = [...macros];
|
||||
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
||||
const updatedMacros = normalizeSortOrders(newMacros);
|
||||
|
||||
await saveMacros(updatedMacros);
|
||||
notifications.success("Macro order updated successfully");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to reorder macros");
|
||||
}
|
||||
|
||||
setActionLoadingId(macro.id);
|
||||
|
||||
const newMacroCopy: KeySequence = {
|
||||
...JSON.parse(JSON.stringify(macro)),
|
||||
id: generateMacroId(),
|
||||
name: `${macro.name} ${COPY_SUFFIX}`,
|
||||
sortOrder: macros.length + 1,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
||||
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to duplicate macro");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
},
|
||||
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
|
||||
);
|
||||
|
||||
const handleMoveMacro = useCallback(
|
||||
async (index: number, direction: "up" | "down", macroId: string) => {
|
||||
if (!Array.isArray(macros) || macros.length === 0) {
|
||||
notifications.error("No macros available");
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= macros.length) return;
|
||||
|
||||
setActionLoadingId(macroId);
|
||||
|
||||
try {
|
||||
const newMacros = [...macros];
|
||||
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
||||
const updatedMacros = normalizeSortOrders(newMacros);
|
||||
|
||||
await saveMacros(updatedMacros);
|
||||
notifications.success("Macro order updated successfully");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to reorder macros");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
},
|
||||
[macros, saveMacros, setActionLoadingId],
|
||||
);
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}, [macros, saveMacros, setActionLoadingId]);
|
||||
|
||||
const handleDeleteMacro = useCallback(async () => {
|
||||
if (!macroToDelete?.id) return;
|
||||
|
||||
setActionLoadingId(macroToDelete.id);
|
||||
try {
|
||||
const updatedMacros = normalizeSortOrders(
|
||||
macros.filter(m => m.id !== macroToDelete.id),
|
||||
);
|
||||
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
|
||||
await saveMacros(updatedMacros);
|
||||
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
|
||||
setShowDeleteConfirm(false);
|
||||
|
@ -139,168 +122,135 @@ export default function SettingsMacrosRoute() {
|
|||
}
|
||||
}, [macroToDelete, macros, saveMacros]);
|
||||
|
||||
const MacroList = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{macros.map((macro, index) => (
|
||||
<Card key={macro.id} className="bg-white p-2 dark:bg-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
onClick={() => handleMoveMacro(index, "up", macro.id)}
|
||||
disabled={index === 0 || actionLoadingId === macro.id}
|
||||
LeadingIcon={LuArrowUp}
|
||||
aria-label={`Move ${macro.name} up`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
onClick={() => handleMoveMacro(index, "down", macro.id)}
|
||||
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
|
||||
LeadingIcon={LuArrowDown}
|
||||
aria-label={`Move ${macro.name} down`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex min-w-0 flex-1 flex-col justify-center">
|
||||
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
||||
{macro.name}
|
||||
</h3>
|
||||
<p className="mt-1 ml-4 overflow-hidden text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
{macro.steps.map((step, stepIndex) => {
|
||||
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
|
||||
|
||||
return (
|
||||
<span key={stepIndex} className="inline-flex items-center">
|
||||
<StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
<span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
|
||||
{(Array.isArray(step.modifiers) &&
|
||||
step.modifiers.length > 0) ||
|
||||
(Array.isArray(step.keys) && step.keys.length > 0) ? (
|
||||
<>
|
||||
{Array.isArray(step.modifiers) &&
|
||||
step.modifiers.map((modifier, idx) => (
|
||||
<Fragment key={`mod-${idx}`}>
|
||||
<span className="font-medium text-slate-600 dark:text-slate-200">
|
||||
{modifierDisplayMap[modifier] || modifier}
|
||||
</span>
|
||||
{idx < step.modifiers.length - 1 && (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{" "}
|
||||
+{" "}
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{Array.isArray(step.modifiers) &&
|
||||
step.modifiers.length > 0 &&
|
||||
Array.isArray(step.keys) &&
|
||||
step.keys.length > 0 && (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{" "}
|
||||
+{" "}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{Array.isArray(step.keys) &&
|
||||
step.keys.map((key, idx) => (
|
||||
<Fragment key={`key-${idx}`}>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
{keyDisplayMap[key] || key}
|
||||
</span>
|
||||
{idx < step.keys.length - 1 && (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{" "}
|
||||
+{" "}
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-slate-500 dark:text-slate-400">
|
||||
Delay only
|
||||
</span>
|
||||
)}
|
||||
{step.delay !== DEFAULT_DELAY && (
|
||||
<span className="ml-1 text-slate-400 dark:text-slate-500">
|
||||
({step.delay}ms)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-1">
|
||||
<Button
|
||||
size="XS"
|
||||
className="text-red-500 dark:text-red-400"
|
||||
theme="light"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={() => {
|
||||
setMacroToDelete(macro);
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Delete macro ${macro.name}`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuCopy}
|
||||
onClick={() => handleDuplicateMacro(macro)}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Duplicate macro ${macro.name}`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuPenLine}
|
||||
text="Edit"
|
||||
onClick={() => navigate(`${macro.id}/edit`)}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Edit macro ${macro.name}`}
|
||||
/>
|
||||
</div>
|
||||
const MacroList = useMemo(() => (
|
||||
<div className="space-y-2">
|
||||
{macros.map((macro, index) => (
|
||||
<Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
onClick={() => handleMoveMacro(index, 'up', macro.id)}
|
||||
disabled={index === 0 || actionLoadingId === macro.id}
|
||||
LeadingIcon={LuArrowUp}
|
||||
aria-label={`Move ${macro.name} up`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
onClick={() => handleMoveMacro(index, 'down', macro.id)}
|
||||
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
|
||||
LeadingIcon={LuArrowDown}
|
||||
aria-label={`Move ${macro.name} down`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setMacroToDelete(null);
|
||||
}}
|
||||
title="Delete Macro"
|
||||
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
|
||||
variant="danger"
|
||||
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
|
||||
onConfirm={handleDeleteMacro}
|
||||
isConfirming={actionLoadingId === macroToDelete?.id}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
macros,
|
||||
showDeleteConfirm,
|
||||
macroToDelete?.name,
|
||||
macroToDelete?.id,
|
||||
actionLoadingId,
|
||||
handleDeleteMacro,
|
||||
handleMoveMacro,
|
||||
handleDuplicateMacro,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
|
||||
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
||||
{macro.name}
|
||||
</h3>
|
||||
<p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
{macro.steps.map((step, stepIndex) => {
|
||||
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
|
||||
|
||||
return (
|
||||
<span key={stepIndex} className="inline-flex items-center">
|
||||
<StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
|
||||
<span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
|
||||
{(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
|
||||
<>
|
||||
{Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
|
||||
<Fragment key={`mod-${idx}`}>
|
||||
<span className="font-medium text-slate-600 dark:text-slate-200">
|
||||
{modifierDisplayMap[modifier] || modifier}
|
||||
</span>
|
||||
{idx < step.modifiers.length - 1 && (
|
||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
|
||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
||||
)}
|
||||
|
||||
{Array.isArray(step.keys) && step.keys.map((key, idx) => (
|
||||
<Fragment key={`key-${idx}`}>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
{keyDisplayMap[key] || key}
|
||||
</span>
|
||||
{idx < step.keys.length - 1 && (
|
||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
|
||||
)}
|
||||
{step.delay !== DEFAULT_DELAY && (
|
||||
<span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<Button
|
||||
size="XS"
|
||||
className="text-red-500 dark:text-red-400"
|
||||
theme="light"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={() => {
|
||||
setMacroToDelete(macro);
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Delete macro ${macro.name}`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuCopy}
|
||||
onClick={() => handleDuplicateMacro(macro)}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Duplicate macro ${macro.name}`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuPenLine}
|
||||
text="Edit"
|
||||
onClick={() => navigate(`${macro.id}/edit`)}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Edit macro ${macro.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setMacroToDelete(null);
|
||||
}}
|
||||
title="Delete Macro"
|
||||
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
|
||||
variant="danger"
|
||||
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
|
||||
onConfirm={handleDeleteMacro}
|
||||
isConfirming={actionLoadingId === macroToDelete?.id}
|
||||
/>
|
||||
</div>
|
||||
), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
@ -309,7 +259,7 @@ export default function SettingsMacrosRoute() {
|
|||
title="Keyboard Macros"
|
||||
description={`Combine keystrokes into a single action for faster workflows.`}
|
||||
/>
|
||||
{macros.length > 0 && (
|
||||
{ macros.length > 0 && (
|
||||
<div className="flex items-center pl-2">
|
||||
<Button
|
||||
size="SM"
|
||||
|
@ -338,7 +288,6 @@ export default function SettingsMacrosRoute() {
|
|||
<EmptyCard
|
||||
IconElm={LuCommand}
|
||||
headline="Create Your First Macro"
|
||||
description="Combine keystrokes into a single action"
|
||||
BtnElm={
|
||||
<Button
|
||||
size="SM"
|
||||
|
@ -350,9 +299,7 @@ export default function SettingsMacrosRoute() {
|
|||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
MacroList
|
||||
)}
|
||||
) : MacroList}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,30 +1,19 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
IPv4Mode,
|
||||
IPv6Mode,
|
||||
LLDPMode,
|
||||
mDNSMode,
|
||||
NetworkSettings,
|
||||
NetworkState,
|
||||
TimeSyncMode,
|
||||
useNetworkStateStore,
|
||||
} from "@/hooks/stores";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
|
||||
import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { Button } from "@components/Button";
|
||||
import { GridCard } from "@components/Card";
|
||||
import InputField from "@components/InputField";
|
||||
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||
import Fieldset from "@/components/Fieldset";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const defaultNetworkSettings: NetworkSettings = {
|
||||
|
@ -36,9 +25,13 @@ const defaultNetworkSettings: NetworkSettings = {
|
|||
lldp_tx_tlvs: [],
|
||||
mdns_mode: "unknown",
|
||||
time_sync_mode: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
|
||||
if (lifetime == "") {
|
||||
return <strong>N/A</strong>;
|
||||
}
|
||||
|
||||
const [remaining, setRemaining] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -50,91 +43,46 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
|
|||
return () => clearInterval(interval);
|
||||
}, [lifetime]);
|
||||
|
||||
if (lifetime == "") {
|
||||
return <strong>N/A</strong>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<strong>{dayjs(lifetime).format("YYYY-MM-DD HH:mm")}</strong>
|
||||
{remaining && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||
({remaining})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <>
|
||||
<strong>{dayjs(lifetime).format()}</strong>
|
||||
{remaining && <>
|
||||
{" "}<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||
({remaining})
|
||||
</span>
|
||||
</>}
|
||||
</>
|
||||
}
|
||||
|
||||
export default function SettingsNetworkRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const [networkState, setNetworkState] = useNetworkStateStore(state => [
|
||||
state,
|
||||
state.setNetworkState,
|
||||
]);
|
||||
|
||||
const [networkSettings, setNetworkSettings] =
|
||||
useState<NetworkSettings>(defaultNetworkSettings);
|
||||
|
||||
// We use this to determine whether the settings have changed
|
||||
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||
const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]);
|
||||
|
||||
const [networkSettings, setNetworkSettings] = useState<NetworkSettings>(defaultNetworkSettings);
|
||||
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
||||
|
||||
const [customDomain, setCustomDomain] = useState<string>("");
|
||||
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
|
||||
|
||||
useEffect(() => {
|
||||
if (networkSettings.domain && networkSettingsLoaded) {
|
||||
// Check if the domain is one of the predefined options
|
||||
const predefinedOptions = ["dhcp", "local"];
|
||||
if (predefinedOptions.includes(networkSettings.domain)) {
|
||||
setSelectedDomainOption(networkSettings.domain);
|
||||
} else {
|
||||
setSelectedDomainOption("custom");
|
||||
setCustomDomain(networkSettings.domain);
|
||||
}
|
||||
}
|
||||
}, [networkSettings.domain, networkSettingsLoaded]);
|
||||
|
||||
const getNetworkSettings = useCallback(() => {
|
||||
setNetworkSettingsLoaded(false);
|
||||
send("getNetworkSettings", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
console.log(resp.result);
|
||||
setNetworkSettings(resp.result as NetworkSettings);
|
||||
|
||||
if (!firstNetworkSettings.current) {
|
||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||
}
|
||||
setNetworkSettingsLoaded(true);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const setNetworkSettingsRemote = useCallback(
|
||||
(settings: NetworkSettings) => {
|
||||
setNetworkSettingsLoaded(false);
|
||||
send("setNetworkSettings", { settings }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
"Failed to save network settings: " +
|
||||
(resp.error.data ? resp.error.data : resp.error.message),
|
||||
);
|
||||
setNetworkSettingsLoaded(true);
|
||||
return;
|
||||
}
|
||||
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
|
||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||
setNetworkSettings(resp.result as NetworkSettings);
|
||||
const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => {
|
||||
setNetworkSettingsLoaded(false);
|
||||
send("setNetworkSettings", { settings }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message));
|
||||
setNetworkSettingsLoaded(true);
|
||||
notifications.success("Network settings saved");
|
||||
});
|
||||
},
|
||||
[send],
|
||||
);
|
||||
return;
|
||||
}
|
||||
setNetworkSettings(resp.result as NetworkSettings);
|
||||
setNetworkSettingsLoaded(true);
|
||||
notifications.success("Network settings saved");
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const getNetworkState = useCallback(() => {
|
||||
send("getNetworkState", {}, resp => {
|
||||
|
@ -142,7 +90,7 @@ export default function SettingsNetworkRoute() {
|
|||
console.log(resp.result);
|
||||
setNetworkState(resp.result as NetworkState);
|
||||
});
|
||||
}, [send, setNetworkState]);
|
||||
}, [send]);
|
||||
|
||||
const handleRenewLease = useCallback(() => {
|
||||
send("renewDHCPLease", {}, resp => {
|
||||
|
@ -183,520 +131,278 @@ export default function SettingsNetworkRoute() {
|
|||
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
|
||||
};
|
||||
|
||||
const handleHostnameChange = (value: string) => {
|
||||
setNetworkSettings({ ...networkSettings, hostname: value });
|
||||
};
|
||||
|
||||
const handleDomainChange = (value: string) => {
|
||||
setNetworkSettings({ ...networkSettings, domain: value });
|
||||
};
|
||||
|
||||
const handleDomainOptionChange = (value: string) => {
|
||||
setSelectedDomainOption(value);
|
||||
if (value !== "custom") {
|
||||
handleDomainChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomDomainChange = (value: string) => {
|
||||
setCustomDomain(value);
|
||||
handleDomainChange(value);
|
||||
};
|
||||
|
||||
const filterUnknown = useCallback(
|
||||
(options: { value: string; label: string }[]) => {
|
||||
if (!networkSettingsLoaded) return options;
|
||||
return options.filter(option => option.value !== "unknown");
|
||||
},
|
||||
[networkSettingsLoaded],
|
||||
);
|
||||
|
||||
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
|
||||
const filterUnknown = useCallback((options: { value: string; label: string; }[]) => {
|
||||
if (!networkSettingsLoaded) return options;
|
||||
return options.filter(option => option.value !== "unknown");
|
||||
}, [networkSettingsLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fieldset disabled={!networkSettingsLoaded} className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Network"
|
||||
description="Configure your network settings"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="MAC Address"
|
||||
description="Hardware identifier for the network interface"
|
||||
>
|
||||
<InputField
|
||||
type="text"
|
||||
size="SM"
|
||||
value={networkState?.mac_address}
|
||||
error={""}
|
||||
disabled={true}
|
||||
readOnly={true}
|
||||
className="dark:!text-opacity-60"
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Hostname"
|
||||
description="Device identifier on the network. Blank for system default"
|
||||
>
|
||||
<div className="relative">
|
||||
<div>
|
||||
<InputField
|
||||
size="SM"
|
||||
type="text"
|
||||
placeholder="jetkvm"
|
||||
defaultValue={networkSettings.hostname}
|
||||
onChange={e => {
|
||||
handleHostnameChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Domain"
|
||||
description="Network domain suffix for the device"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={selectedDomainOption}
|
||||
onChange={e => handleDomainOptionChange(e.target.value)}
|
||||
options={[
|
||||
{ value: "dhcp", label: "DHCP provided" },
|
||||
{ value: "local", label: ".local" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
{selectedDomainOption === "custom" && (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<InputField
|
||||
size="SM"
|
||||
type="text"
|
||||
placeholder="home"
|
||||
value={customDomain}
|
||||
onChange={e => setCustomDomain(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Save Domain"
|
||||
onClick={() => handleCustomDomainChange(customDomain)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="mDNS"
|
||||
description="Control mDNS (multicast DNS) operational mode"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.mdns_mode}
|
||||
onChange={e => handleMdnsModeChange(e.target.value)}
|
||||
options={filterUnknown([
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "ipv4_only", label: "IPv4 only" },
|
||||
{ value: "ipv6_only", label: "IPv6 only" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Time synchronization"
|
||||
description="Configure time synchronization settings"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.time_sync_mode}
|
||||
onChange={e => {
|
||||
handleTimeSyncModeChange(e.target.value);
|
||||
}}
|
||||
options={filterUnknown([
|
||||
{ value: "unknown", label: "..." },
|
||||
// { value: "auto", label: "Auto" },
|
||||
{ value: "ntp_only", label: "NTP only" },
|
||||
{ value: "ntp_and_http", label: "NTP and HTTP" },
|
||||
{ value: "http_only", label: "HTTP only" },
|
||||
// { value: "custom", label: "Custom" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
disabled={firstNetworkSettings.current === networkSettings}
|
||||
text="Save Settings"
|
||||
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Network"
|
||||
description="Configure your network settings"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="MAC Address"
|
||||
description={<></>}
|
||||
>
|
||||
<span className="select-auto font-mono text-xs text-slate-700 dark:text-slate-300">
|
||||
{networkState?.mac_address}
|
||||
</span>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Hostname"
|
||||
description={
|
||||
<>
|
||||
Hostname for the device
|
||||
<br />
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||
Leave blank for default
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputField
|
||||
type="text"
|
||||
placeholder="jetkvm"
|
||||
value={networkSettings.hostname}
|
||||
error={""}
|
||||
onChange={e => {
|
||||
setNetworkSettings({ ...networkSettings, hostname: e.target.value });
|
||||
}}
|
||||
disabled={!networkSettingsLoaded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode">
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.ipv4_mode}
|
||||
onChange={e => handleIpv4ModeChange(e.target.value)}
|
||||
options={filterUnknown([
|
||||
{ value: "dhcp", label: "DHCP" },
|
||||
// { value: "static", label: "Static" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{networkState?.dhcp_lease && (
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Domain"
|
||||
description={
|
||||
<>
|
||||
Domain for the device
|
||||
<br />
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||
Leave blank to use DHCP provided domain, if there is no domain, use <span className="font-mono">local</span>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputField
|
||||
type="text"
|
||||
placeholder="local"
|
||||
value={networkSettings.domain}
|
||||
error={""}
|
||||
onChange={e => {
|
||||
setNetworkSettings({ ...networkSettings, domain: e.target.value });
|
||||
}}
|
||||
disabled={!networkSettingsLoaded}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="IPv4 Mode"
|
||||
description="Configure the IPv4 mode"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.ipv4_mode}
|
||||
onChange={e => handleIpv4ModeChange(e.target.value)}
|
||||
disabled={!networkSettingsLoaded}
|
||||
options={filterUnknown([
|
||||
{ value: "dhcp", label: "DHCP" },
|
||||
// { value: "static", label: "Static" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{networkState?.dhcp_lease && (
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4">
|
||||
<div className="space-y-3 w-full">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
DHCP Lease
|
||||
Current DHCP Lease
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-x-6 gap-y-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
{networkState?.dhcp_lease?.ip && (
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
IP Address
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.ip}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.netmask && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Subnet Mask
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.netmask}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.dns && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
DNS Servers
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.dns.map(dns => (
|
||||
<div key={dns}>{dns}</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.broadcast && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Broadcast
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.broadcast}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.domain && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Domain
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.domain}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.ntp_servers &&
|
||||
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
|
||||
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
|
||||
NTP Servers
|
||||
</div>
|
||||
<div className="shrink text-right text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.ntp_servers.map(server => (
|
||||
<div key={server}>{server}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.hostname && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Hostname
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.hostname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{networkState?.dhcp_lease?.routers &&
|
||||
networkState?.dhcp_lease?.routers.length > 0 && (
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Gateway
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.routers.map(router => (
|
||||
<div key={router}>{router}</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.server_id && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
DHCP Server
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.server_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.lease_expiry && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Lease Expires
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
<LifeTimeLabel
|
||||
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.mtu && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
MTU
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.mtu}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.ttl && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
TTL
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.ttl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.bootp_next_server && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Boot Next Server
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.bootp_next_server}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.bootp_server_name && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Boot Server Name
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.bootp_server_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.bootp_file && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Boot File
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.bootp_file}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
className="text-red-500"
|
||||
text="Renew DHCP Lease"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
onClick={() => setShowRenewLeaseConfirm(true)}
|
||||
/>
|
||||
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
|
||||
{networkState?.dhcp_lease?.ip && <li>IP: <strong>{networkState?.dhcp_lease?.ip}</strong></li>}
|
||||
{networkState?.dhcp_lease?.netmask && <li>Subnet: <strong>{networkState?.dhcp_lease?.netmask}</strong></li>}
|
||||
{networkState?.dhcp_lease?.broadcast && <li>Broadcast: <strong>{networkState?.dhcp_lease?.broadcast}</strong></li>}
|
||||
{networkState?.dhcp_lease?.ttl && <li>TTL: <strong>{networkState?.dhcp_lease?.ttl}</strong></li>}
|
||||
{networkState?.dhcp_lease?.mtu && <li>MTU: <strong>{networkState?.dhcp_lease?.mtu}</strong></li>}
|
||||
{networkState?.dhcp_lease?.hostname && <li>Hostname: <strong>{networkState?.dhcp_lease?.hostname}</strong></li>}
|
||||
{networkState?.dhcp_lease?.domain && <li>Domain: <strong>{networkState?.dhcp_lease?.domain}</strong></li>}
|
||||
{networkState?.dhcp_lease?.routers && <li>Gateway: <strong>{networkState?.dhcp_lease?.routers.join(", ")}</strong></li>}
|
||||
{networkState?.dhcp_lease?.dns && <li>DNS: <strong>{networkState?.dhcp_lease?.dns.join(", ")}</strong></li>}
|
||||
{networkState?.dhcp_lease?.ntp_servers && <li>NTP Servers: <strong>{networkState?.dhcp_lease?.ntp_servers.join(", ")}</strong></li>}
|
||||
{networkState?.dhcp_lease?.server_id && <li>Server ID: <strong>{networkState?.dhcp_lease?.server_id}</strong></li>}
|
||||
{networkState?.dhcp_lease?.bootp_next_server && <li>BootP Next Server: <strong>{networkState?.dhcp_lease?.bootp_next_server}</strong></li>}
|
||||
{networkState?.dhcp_lease?.bootp_server_name && <li>BootP Server Name: <strong>{networkState?.dhcp_lease?.bootp_server_name}</strong></li>}
|
||||
{networkState?.dhcp_lease?.bootp_file && <li>Boot File: <strong>{networkState?.dhcp_lease?.bootp_file}</strong></li>}
|
||||
{networkState?.dhcp_lease?.lease_expiry && <li>
|
||||
Lease Expiry: <LifeTimeLabel lifetime={`${networkState?.dhcp_lease?.lease_expiry}`} />
|
||||
</li>}
|
||||
{/* {JSON.stringify(networkState?.dhcp_lease)} */}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="block w-full dark:border-slate-600" />
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text="Renew lease"
|
||||
onClick={() => {
|
||||
handleRenewLease();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.ipv6_mode}
|
||||
onChange={e => handleIpv6ModeChange(e.target.value)}
|
||||
options={filterUnknown([
|
||||
// { value: "disabled", label: "Disabled" },
|
||||
{ value: "slaac", label: "SLAAC" },
|
||||
// { value: "dhcpv6", label: "DHCPv6" },
|
||||
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
|
||||
// { value: "static", label: "Static" },
|
||||
// { value: "link_local", label: "Link-local only" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{networkState?.ipv6_addresses && (
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="IPv6 Mode"
|
||||
description="Configure the IPv6 mode"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.ipv6_mode}
|
||||
onChange={e => handleIpv6ModeChange(e.target.value)}
|
||||
disabled={!networkSettingsLoaded}
|
||||
options={filterUnknown([
|
||||
// { value: "disabled", label: "Disabled" },
|
||||
{ value: "slaac", label: "SLAAC" },
|
||||
// { value: "dhcpv6", label: "DHCPv6" },
|
||||
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
|
||||
// { value: "static", label: "Static" },
|
||||
// { value: "link_local", label: "Link-local only" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{networkState?.ipv6_addresses && (
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4">
|
||||
<div className="space-y-3 w-full">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
IPv6 Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
||||
{networkState?.dhcp_lease?.ip && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Link-local
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.ipv6_link_local}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
{networkState?.ipv6_addresses &&
|
||||
networkState?.ipv6_addresses.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
|
||||
{networkState.ipv6_addresses.map(addr => (
|
||||
<div
|
||||
key={addr.address}
|
||||
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-slate-100/40 p-4 pl-4 dark:border-blue-500 dark:bg-slate-900"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div className="col-span-2 flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Address
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.address}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{addr.valid_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Valid Lifetime
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.valid_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
N/A
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel
|
||||
lifetime={`${addr.valid_lifetime}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{addr.preferred_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Preferred Lifetime
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.preferred_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
N/A
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel
|
||||
lifetime={`${addr.preferred_lifetime}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||
IPv6 Link-local
|
||||
</h4>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
{networkState?.ipv6_link_local}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||
IPv6 Addresses
|
||||
</h4>
|
||||
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
|
||||
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => (
|
||||
<li key={addr.address}>
|
||||
{addr.address}
|
||||
{addr.valid_lifetime && <>
|
||||
<br />
|
||||
- valid_lft: {" "}
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
|
||||
</span>
|
||||
</>}
|
||||
{addr.preferred_lifetime && <>
|
||||
<br />
|
||||
- pref_lft: {" "}
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
|
||||
</span>
|
||||
</>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden space-y-4">
|
||||
<SettingsItem
|
||||
title="LLDP"
|
||||
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.lldp_mode}
|
||||
onChange={e => handleLldpModeChange(e.target.value)}
|
||||
options={filterUnknown([
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
{ value: "basic", label: "Basic" },
|
||||
{ value: "all", label: "All" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</Fieldset>
|
||||
<ConfirmDialog
|
||||
open={showRenewLeaseConfirm}
|
||||
onClose={() => setShowRenewLeaseConfirm(false)}
|
||||
title="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."
|
||||
variant="danger"
|
||||
confirmText="Renew Lease"
|
||||
onConfirm={() => {
|
||||
handleRenewLease();
|
||||
setShowRenewLeaseConfirm(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 hidden">
|
||||
<SettingsItem
|
||||
title="LLDP"
|
||||
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.lldp_mode}
|
||||
onChange={e => handleLldpModeChange(e.target.value)}
|
||||
disabled={!networkSettingsLoaded}
|
||||
options={filterUnknown([
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
{ value: "basic", label: "Basic" },
|
||||
{ value: "all", label: "All" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="mDNS"
|
||||
description="Control mDNS (multicast DNS) operational mode"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.mdns_mode}
|
||||
onChange={e => handleMdnsModeChange(e.target.value)}
|
||||
disabled={!networkSettingsLoaded}
|
||||
options={filterUnknown([
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "ipv4_only", label: "IPv4 only" },
|
||||
{ value: "ipv6_only", label: "IPv6 only" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Time synchronization"
|
||||
description="Configure time synchronization settings"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.time_sync_mode}
|
||||
onChange={e => handleTimeSyncModeChange(e.target.value)}
|
||||
disabled={!networkSettingsLoaded}
|
||||
options={filterUnknown([
|
||||
{ value: "unknown", label: "..." },
|
||||
// { value: "auto", label: "Auto" },
|
||||
{ value: "ntp_only", label: "NTP only" },
|
||||
{ value: "ntp_and_http", label: "NTP and HTTP" },
|
||||
{ value: "http_only", label: "HTTP only" },
|
||||
// { value: "custom", label: "Custom" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNetworkSettingsRemote(networkSettings);
|
||||
}}
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Save Settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,16 +13,15 @@ import {
|
|||
LuNetwork,
|
||||
} from "react-icons/lu";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useResizeObserver } from "usehooks-ts";
|
||||
|
||||
import Card from "@/components/Card";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import { useUiStore } from "@/hooks/stores";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
|
||||
import { LinkButton } from "../components/Button";
|
||||
import { cx } from "../cva.config";
|
||||
|
||||
import { useUiStore } from "../hooks/stores";
|
||||
import useKeyboard from "../hooks/useKeyboard";
|
||||
import { useResizeObserver } from "../hooks/useResizeObserver";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
export default function SettingsRoute() {
|
||||
|
@ -32,7 +31,7 @@ export default function SettingsRoute() {
|
|||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject<HTMLDivElement> });
|
||||
const { width } = useResizeObserver({ ref: scrollContainerRef });
|
||||
|
||||
// Handle scroll position to show/hide gradients
|
||||
const handleScroll = () => {
|
||||
|
|
|
@ -56,7 +56,7 @@ export default function SetupRoute() {
|
|||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen grid-rows-(--grid-layout)">
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import { FocusTrap } from "focus-trap-react";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
|
||||
|
@ -795,7 +795,7 @@ export default function KvmIdRoute() {
|
|||
</div>
|
||||
</FocusTrap>
|
||||
|
||||
<div className="grid h-full grid-rows-(--grid-headerBody) select-none">
|
||||
<div className="grid h-full select-none grid-rows-headerBody">
|
||||
<DashboardNavbar
|
||||
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
|
||||
showConnectionStatus={true}
|
||||
|
@ -809,7 +809,7 @@ export default function KvmIdRoute() {
|
|||
<WebRTCVideo />
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
|
||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4 opacity-0"
|
||||
>
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||
|
|
|
@ -8,7 +8,7 @@ export default function DevicesAlreadyAdopted() {
|
|||
<>
|
||||
<GridBackground />
|
||||
|
||||
<div className="grid min-h-screen grid-rows-(--grid-layout)">
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { useLoaderData, useRevalidator } from "react-router-dom";
|
||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import EmptyCard from "@components/EmptyCard";
|
||||
import KvmCard from "@components/KvmCard";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import { User } from "@/hooks/stores";
|
||||
import KvmCard from "@components/KvmCard";
|
||||
import useInterval from "@/hooks/useInterval";
|
||||
import { checkAuth } from "@/main";
|
||||
import { User } from "@/hooks/stores";
|
||||
import EmptyCard from "@components/EmptyCard";
|
||||
import { CLOUD_API } from "@/ui.config";
|
||||
|
||||
interface LoaderData {
|
||||
|
@ -16,7 +16,7 @@ interface LoaderData {
|
|||
user: User;
|
||||
}
|
||||
|
||||
const loader = async () => {
|
||||
export const loader = async () => {
|
||||
const user = await checkAuth();
|
||||
|
||||
try {
|
||||
|
@ -40,7 +40,7 @@ export default function DevicesRoute() {
|
|||
useInterval(revalidate.revalidate, 4000);
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="grid h-full select-none grid-rows-(--grid-headerBody)">
|
||||
<div className="grid h-full select-none grid-rows-headerBody">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
|
@ -101,5 +101,3 @@ export default function DevicesRoute() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DevicesRoute.loader = loader;
|
||||
|
|
|
@ -56,7 +56,7 @@ export default function LoginLocalRoute() {
|
|||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen grid-rows-(--grid-layout)">
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="isolate flex h-full w-full items-center justify-center">
|
||||
|
|
|
@ -14,6 +14,7 @@ import api from "../api";
|
|||
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${DEVICE_API}/device/status`)
|
||||
|
@ -58,24 +59,18 @@ export default function WelcomeLocalModeRoute() {
|
|||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
<Container>
|
||||
<div className="isolate flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-xl space-y-8">
|
||||
<div className="animate-fadeIn flex items-center justify-center opacity-0">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt=""
|
||||
className="-ml-4 hidden h-[32px] dark:block"
|
||||
/>
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn space-y-2 text-center opacity-0"
|
||||
className="space-y-2 text-center opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Local Authentication Method
|
||||
</h1>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Local Authentication Method</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Select how you{"'"}d like to secure your JetKVM device locally.
|
||||
</p>
|
||||
|
@ -83,7 +78,7 @@ export default function WelcomeLocalModeRoute() {
|
|||
|
||||
<Form method="POST" className="space-y-8">
|
||||
<div
|
||||
className="animate-fadeIn grid grid-cols-1 gap-6 opacity-0 sm:grid-cols-2"
|
||||
className="grid grid-cols-1 gap-6 opacity-0 animate-fadeIn sm:grid-cols-2"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
{["password", "noPassword"].map(mode => (
|
||||
|
@ -95,14 +90,14 @@ export default function WelcomeLocalModeRoute() {
|
|||
})}
|
||||
>
|
||||
<div
|
||||
className="relative flex cursor-pointer flex-col items-center p-6 select-none"
|
||||
className="relative flex flex-col items-center p-6 cursor-pointer select-none"
|
||||
onClick={() => setSelectedMode(mode as "password" | "noPassword")}
|
||||
>
|
||||
<div className="space-y-0 text-center">
|
||||
<h3 className="text-base font-bold text-black dark:text-white">
|
||||
{mode === "password" ? "Password protected" : "No Password"}
|
||||
</h3>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="mt-2 text-sm text-center text-gray-600 dark:text-gray-400">
|
||||
{mode === "password"
|
||||
? "Secure your device with a password for added protection."
|
||||
: "Quick access without password authentication."}
|
||||
|
@ -116,7 +111,7 @@ export default function WelcomeLocalModeRoute() {
|
|||
onChange={() => {
|
||||
setSelectedMode(mode as "password" | "noPassword");
|
||||
}}
|
||||
className="absolute top-2 right-2 h-4 w-4 text-blue-600"
|
||||
className="absolute w-4 h-4 text-blue-600 right-2 top-2"
|
||||
/>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
@ -125,7 +120,7 @@ export default function WelcomeLocalModeRoute() {
|
|||
|
||||
{actionData?.error && (
|
||||
<p
|
||||
className="animate-fadeIn text-center text-sm text-red-600 opacity-0 dark:text-red-400"
|
||||
className="text-sm text-center text-red-600 opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
{actionData.error}
|
||||
|
@ -133,7 +128,7 @@ export default function WelcomeLocalModeRoute() {
|
|||
)}
|
||||
|
||||
<div
|
||||
className="animate-fadeIn mx-auto max-w-sm opacity-0"
|
||||
className="max-w-sm mx-auto opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
<Button
|
||||
|
@ -149,7 +144,7 @@ export default function WelcomeLocalModeRoute() {
|
|||
</Form>
|
||||
|
||||
<p
|
||||
className="animate-fadeIn mx-auto max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
|
||||
className="max-w-md mx-auto text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
You can always change your authentication method later in the settings.
|
||||
|
|
|
@ -69,34 +69,28 @@ export default function WelcomeLocalPasswordRoute() {
|
|||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
<Container>
|
||||
<div className="isolate flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div className="animate-fadeIn flex items-center justify-center opacity-0">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt=""
|
||||
className="-ml-4 hidden h-[32px] dark:block"
|
||||
/>
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn space-y-2 text-center opacity-0"
|
||||
className="space-y-2 text-center opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Set a Password
|
||||
</h1>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Set a Password</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Create a strong password to secure your JetKVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="mx-auto max-w-sm space-y-4">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="animate-fadeIn opacity-0"
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
|
@ -112,21 +106,21 @@ export default function WelcomeLocalPasswordRoute() {
|
|||
onClick={() => setShowPassword(false)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEye className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setShowPassword(true)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEyeOff className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="animate-fadeIn opacity-0"
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
|
@ -143,7 +137,7 @@ export default function WelcomeLocalPasswordRoute() {
|
|||
{actionData?.error && <p className="text-sm text-red-600">{}</p>}
|
||||
|
||||
<div
|
||||
className="animate-fadeIn opacity-0"
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
<Button
|
||||
|
@ -159,7 +153,7 @@ export default function WelcomeLocalPasswordRoute() {
|
|||
</Fieldset>
|
||||
|
||||
<p
|
||||
className="animate-fadeIn max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
|
||||
className="max-w-md text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
|
||||
style={{ animationDelay: "800ms" }}
|
||||
>
|
||||
This password will be used to secure your device data and protect against
|
||||
|
|
|
@ -13,6 +13,8 @@ import { DEVICE_API } from "@/ui.config";
|
|||
|
||||
import api from "../api";
|
||||
|
||||
|
||||
|
||||
export interface DeviceStatus {
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
@ -41,24 +43,19 @@ export default function WelcomeRoute() {
|
|||
<div className="grid min-h-screen">
|
||||
{imageLoaded && (
|
||||
<Container>
|
||||
<div className="isolate flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-3xl text-center">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="animate-fadeIn animation-delay-1000 flex items-center justify-center opacity-0">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="hidden h-[32px] dark:block"
|
||||
/>
|
||||
<img
|
||||
src={LogoBlueIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[32px] dark:hidden"
|
||||
/>
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn animation-delay-1000">
|
||||
<img src={LogoWhiteIcon} alt="JetKVM Logo" className="h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="JetKVM Logo" className="h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div className="animate-fadeIn animation-delay-1500 space-y-1 opacity-0">
|
||||
<div
|
||||
className="space-y-1 opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "1500ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Welcome to JetKVM
|
||||
</h1>
|
||||
|
@ -72,19 +69,22 @@ export default function WelcomeRoute() {
|
|||
<img
|
||||
src={DeviceImage}
|
||||
alt="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-0 max-w-md scale-[0.98] animate-fadeInScaleFloat opacity-0 transition-all duration-1000 ease-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-8 space-y-4">
|
||||
<p
|
||||
style={{ animationDelay: "2000ms" }}
|
||||
className="animate-fadeIn mx-auto max-w-lg text-lg text-slate-700 opacity-0 dark:text-slate-300"
|
||||
className="max-w-lg mx-auto text-lg opacity-0 animate-fadeIn text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
JetKVM combines powerful hardware with intuitive software to provide a
|
||||
seamless remote control experience.
|
||||
</p>
|
||||
<div className="animate-fadeIn animation-delay-2300 opacity-0">
|
||||
<div
|
||||
style={{ animationDelay: "2300ms" }}
|
||||
className="opacity-0 animate-fadeIn"
|
||||
>
|
||||
<LinkButton
|
||||
size="LG"
|
||||
theme="light"
|
||||
|
|
|
@ -5,9 +5,98 @@ import plugin from "tailwindcss/plugin";
|
|||
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: ["./src/**/*.{ts,tsx,svg}", "./index.html"],
|
||||
darkMode: "selector",
|
||||
theme: {
|
||||
extend: {
|
||||
gridTemplateRows: {
|
||||
layout: "auto 1fr auto",
|
||||
headerBody: "auto 1fr",
|
||||
bodyFooter: "1fr auto",
|
||||
},
|
||||
gridTemplateColumns: {
|
||||
sidebar: "1fr minmax(360px, 25%)",
|
||||
},
|
||||
screens: {
|
||||
xs: "480px",
|
||||
"2xl": "1440px",
|
||||
"3xl": "1920px",
|
||||
"4xl": "2560px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Circular", ...defaultTheme.fontFamily.sans],
|
||||
display: ["Circular", ...defaultTheme.fontFamily.sans],
|
||||
mono: ["Source Code Pro Variable", ...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
maxWidth: {
|
||||
"8xl": "88rem",
|
||||
"9xl": "96rem",
|
||||
"10xl": "104rem",
|
||||
"11xl": "112rem",
|
||||
"12xl": "120rem",
|
||||
},
|
||||
animation: {
|
||||
enter: "enter .2s ease-out",
|
||||
leave: "leave .15s ease-in forwards",
|
||||
fadeInScale: "fadeInScale 1s ease-out forwards",
|
||||
fadeInScaleFloat:
|
||||
"fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite",
|
||||
fadeIn: "fadeIn 1s ease-out forwards",
|
||||
slideUpFade: "slideUpFade 1s ease-out forwards",
|
||||
},
|
||||
animationDelay: {
|
||||
1000: "1000ms",
|
||||
1500: "1500ms",
|
||||
},
|
||||
keyframes: {
|
||||
enter: {
|
||||
"0%": {
|
||||
opacity: "0",
|
||||
transform: "scale(.9)",
|
||||
},
|
||||
"100%": {
|
||||
opacity: "1",
|
||||
transform: "scale(1)",
|
||||
},
|
||||
},
|
||||
leave: {
|
||||
"0%": {
|
||||
opacity: "1",
|
||||
transform: "scale(1)",
|
||||
},
|
||||
"100%": {
|
||||
opacity: "0",
|
||||
transform: "scale(.9)",
|
||||
},
|
||||
},
|
||||
fadeInScale: {
|
||||
"0%": { opacity: "0", transform: "scale(0.98)" },
|
||||
"100%": { opacity: "1", transform: "scale(1)" },
|
||||
},
|
||||
fadeInScaleFloat: {
|
||||
"0%": { opacity: "0", transform: "scale(0.98) translateY(10px)" },
|
||||
"100%": { opacity: "1", transform: "scale(1) translateY(0)" },
|
||||
},
|
||||
float: {
|
||||
"0%, 100%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-10px)" },
|
||||
},
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0", transform: "translateY(10px)" },
|
||||
"70%": { opacity: "0.8", transform: "translateY(1px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
slideUpFade: {
|
||||
"0%": { opacity: "0", transform: "translateY(20px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("@headlessui/tailwindcss"),
|
||||
plugin(function ({ addVariant }) {
|
||||
addVariant("disabled-within", `&:has(input:is(:disabled),button:is(:disabled))`);
|
||||
}),
|
||||
|
@ -53,5 +142,12 @@ export default {
|
|||
},
|
||||
);
|
||||
},
|
||||
function ({ addUtilities, theme }) {
|
||||
const animationDelays = theme("animationDelay");
|
||||
const utilities = Object.entries(animationDelays).map(([key, value]) => ({
|
||||
[`.animation-delay-${key}`]: { animationDelay: value },
|
||||
}));
|
||||
addUtilities(utilities);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
|
||||
|
@ -17,11 +16,7 @@ export default defineConfig(({ mode, command }) => {
|
|||
const { JETKVM_PROXY_URL, USE_SSL } = process.env;
|
||||
const useSSL = USE_SSL === "true";
|
||||
|
||||
const plugins = [
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
react()
|
||||
];
|
||||
const plugins = [tsconfigPaths(), react()];
|
||||
if (useSSL) {
|
||||
plugins.push(basicSsl());
|
||||
}
|
||||
|
|
|
@ -26,19 +26,6 @@ func writeFile(path string, data string) error {
|
|||
return os.WriteFile(path, []byte(data), 0644)
|
||||
}
|
||||
|
||||
func getMassStorageImage() (string, error) {
|
||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get mass storage path: %w", err)
|
||||
}
|
||||
|
||||
imagePath, err := os.ReadFile(path.Join(massStorageFunctionPath, "file"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get mass storage image path: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(imagePath)), nil
|
||||
}
|
||||
|
||||
func setMassStorageImage(imagePath string) error {
|
||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||
if err != nil {
|
||||
|
@ -52,21 +39,19 @@ func setMassStorageImage(imagePath string) error {
|
|||
}
|
||||
|
||||
func setMassStorageMode(cdrom bool) error {
|
||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get mass storage path: %w", err)
|
||||
}
|
||||
|
||||
mode := "0"
|
||||
if cdrom {
|
||||
mode = "1"
|
||||
}
|
||||
|
||||
err, changed := gadget.OverrideGadgetConfig("mass_storage_lun0", "cdrom", mode)
|
||||
if err != nil {
|
||||
if err := writeFile(path.Join(massStorageFunctionPath, "lun.0", "cdrom"), mode); err != nil {
|
||||
return fmt.Errorf("failed to set cdrom mode: %w", err)
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gadget.UpdateGadgetConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
func onDiskMessage(msg webrtc.DataChannelMessage) {
|
||||
|
@ -94,20 +79,9 @@ var nbdDevice *NBDDevice
|
|||
|
||||
const imagesFolder = "/userdata/jetkvm/images"
|
||||
|
||||
func initImagesFolder() error {
|
||||
err := os.MkdirAll(imagesFolder, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create images folder: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcMountBuiltInImage(filename string) error {
|
||||
logger.Info().Str("filename", filename).Msg("Mount Built-In Image")
|
||||
if err := initImagesFolder(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(imagesFolder, 0755)
|
||||
imagePath := filepath.Join(imagesFolder, filename)
|
||||
|
||||
// Check if the file exists in the imagesFolder
|
||||
|
@ -139,17 +113,20 @@ func rpcMountBuiltInImage(filename string) error {
|
|||
return mountImage(imagePath)
|
||||
}
|
||||
|
||||
func getMassStorageCDROMEnabled() (bool, error) {
|
||||
func getMassStorageMode() (bool, error) {
|
||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get mass storage path: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(path.Join(massStorageFunctionPath, "cdrom"))
|
||||
|
||||
data, err := os.ReadFile(path.Join(massStorageFunctionPath, "lun.0", "cdrom"))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read cdrom mode: %w", err)
|
||||
}
|
||||
|
||||
// Trim any whitespace characters. It has a newline at the end
|
||||
trimmedData := strings.TrimSpace(string(data))
|
||||
|
||||
return trimmedData == "1", nil
|
||||
}
|
||||
|
||||
|
@ -214,61 +191,6 @@ func rpcUnmountImage() error {
|
|||
|
||||
var httpRangeReader *httpreadat.RangeReader
|
||||
|
||||
func getInitialVirtualMediaState() (*VirtualMediaState, error) {
|
||||
cdromEnabled, err := getMassStorageCDROMEnabled()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mass storage cdrom enabled: %w", err)
|
||||
}
|
||||
|
||||
diskPath, err := getMassStorageImage()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mass storage image: %w", err)
|
||||
}
|
||||
|
||||
initialState := &VirtualMediaState{
|
||||
Source: Storage,
|
||||
Mode: Disk,
|
||||
}
|
||||
|
||||
if cdromEnabled {
|
||||
initialState.Mode = CDROM
|
||||
}
|
||||
|
||||
// TODO: check if it's WebRTC or HTTP
|
||||
switch diskPath {
|
||||
case "":
|
||||
return nil, nil
|
||||
case "/dev/nbd0":
|
||||
initialState.Source = HTTP
|
||||
initialState.URL = "/"
|
||||
initialState.Size = 1
|
||||
default:
|
||||
initialState.Filename = filepath.Base(diskPath)
|
||||
// get size from file
|
||||
logger.Info().Str("diskPath", diskPath).Msg("getting file size")
|
||||
info, err := os.Stat(diskPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
initialState.Size = info.Size()
|
||||
}
|
||||
|
||||
return initialState, nil
|
||||
}
|
||||
|
||||
func setInitialVirtualMediaState() error {
|
||||
virtualMediaStateMutex.Lock()
|
||||
defer virtualMediaStateMutex.Unlock()
|
||||
initialState, err := getInitialVirtualMediaState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get initial virtual media state: %w", err)
|
||||
}
|
||||
currentVirtualMediaState = initialState
|
||||
|
||||
logger.Info().Interface("initial_virtual_media_state", initialState).Msg("initial virtual media state set")
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcMountWithHTTP(url string, mode VirtualMediaMode) error {
|
||||
virtualMediaStateMutex.Lock()
|
||||
if currentVirtualMediaState != nil {
|
||||
|
@ -282,11 +204,6 @@ func rpcMountWithHTTP(url string, mode VirtualMediaMode) error {
|
|||
return fmt.Errorf("failed to use http url: %w", err)
|
||||
}
|
||||
logger.Info().Str("url", url).Int64("size", n).Msg("using remote url")
|
||||
|
||||
if err := setMassStorageMode(mode == CDROM); err != nil {
|
||||
return fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||
}
|
||||
|
||||
currentVirtualMediaState = &VirtualMediaState{
|
||||
Source: HTTP,
|
||||
Mode: mode,
|
||||
|
@ -326,11 +243,6 @@ func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) erro
|
|||
Size: size,
|
||||
}
|
||||
virtualMediaStateMutex.Unlock()
|
||||
|
||||
if err := setMassStorageMode(mode == CDROM); err != nil {
|
||||
return fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
|
||||
logger.Debug().Msg("Starting nbd device")
|
||||
nbdDevice = NewNBDDevice()
|
||||
|
@ -368,10 +280,6 @@ func rpcMountWithStorage(filename string, mode VirtualMediaMode) error {
|
|||
return fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
if err := setMassStorageMode(mode == CDROM); err != nil {
|
||||
return fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||
}
|
||||
|
||||
err = setMassStorageImage(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mass storage image: %w", err)
|
||||
|
|
Loading…
Reference in New Issue