feat(network): enhance network settings UI (#364)

* feat(network): enhance network settings UI with domain management and improved layout

- Added custom domain input and selection options for DHCP and local domains.
- Improved layout for displaying network settings, including DHCP lease information and IPv6 addresses.
- Refactored state management for network settings and added handlers for hostname and domain changes.
- Updated the display of network settings to enhance user experience and accessibility.

* Re-add save button

* fix: add ConfirmDialog for renewing DHCP lease and improve network settings layout

- Integrated ConfirmDialog component to confirm DHCP lease renewal.
- Enhanced the layout of network settings, including better organization of IPv4 and IPv6 information.
- Updated state management for displaying network settings and lease information.
- Improved user experience with clearer descriptions and structured UI elements.

* Fix lint errors

* fix: useRef TS2554

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
This commit is contained in:
Adam Shiervani
2025-05-14 17:25:56 +02:00
committed by GitHub
parent 2aa7b8569f
commit 340babac24
8 changed files with 1045 additions and 631 deletions

View File

@@ -1,7 +1,14 @@
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 {
@@ -22,7 +29,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[];
@@ -48,72 +55,68 @@ 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 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 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}
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}
/>
</Card>
{options().length > 0 && (
<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"
)}
<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",
)}
>
{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 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 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>
)}
</>
)}
</HeadlessCombobox>
);
}
}

View File

@@ -1,4 +1,9 @@
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";
@@ -42,12 +47,15 @@ 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,
@@ -65,13 +73,18 @@ 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="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="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="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:mt-0 sm:ml-4 sm:text-left">
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
{title}
</h2>
@@ -83,12 +96,7 @@ 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"
@@ -103,4 +111,4 @@ export function ConfirmDialog({
</div>
</Modal>
);
}
}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
@@ -7,16 +6,23 @@ 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 {
@@ -53,16 +59,18 @@ 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" },
};
}
}
@@ -87,7 +95,10 @@ 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;
@@ -97,7 +108,9 @@ 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;
@@ -105,7 +118,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;
@@ -127,7 +140,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 };
@@ -148,9 +161,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 });
};
@@ -181,7 +194,10 @@ 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
@@ -199,18 +215,24 @@ 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}
/>
))}
@@ -223,18 +245,20 @@ 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({});
@@ -257,15 +281,10 @@ 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>
</>
);
}
}

View File

@@ -3,11 +3,11 @@ 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;
@@ -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&apos;s compatible and
functioning correctly
If using an adapter, ensure it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>

View File

@@ -151,7 +151,7 @@ export default function WebRTCVideo() {
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted) {
if ("keyboard" in navigator) {
// @ts-ignore
// @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock();
}
}