27 changed files with 530 additions and 249 deletions
@ -0,0 +1,127 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm } from 'react-hook-form'; |
||||
|
import { FiEdit3, FiSave } from 'react-icons/fi'; |
||||
|
|
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
import { connection } from '../core/connection.js'; |
||||
|
import { EnumSelect } from './generic/form/EnumSelect.jsx'; |
||||
|
import { Input } from './generic/form/Input.jsx'; |
||||
|
import { IconButton } from './generic/IconButton.jsx'; |
||||
|
|
||||
|
export interface ChannelProps { |
||||
|
channel: Protobuf.Channel; |
||||
|
} |
||||
|
interface DotProps { |
||||
|
role: Protobuf.Channel_Role; |
||||
|
admin: boolean; |
||||
|
} |
||||
|
const Dot = ({ role, admin }: DotProps): JSX.Element => ( |
||||
|
<div |
||||
|
className={`h-3 my-auto w-3 rounded-full ${ |
||||
|
role === Protobuf.Channel_Role.PRIMARY |
||||
|
? 'bg-green-500' |
||||
|
: admin |
||||
|
? 'bg-amber-400' |
||||
|
: role === Protobuf.Channel_Role.SECONDARY |
||||
|
? 'bg-cyan-500' |
||||
|
: 'bg-gray-400' |
||||
|
}`}
|
||||
|
/> |
||||
|
); |
||||
|
|
||||
|
export const Channel = ({ channel }: ChannelProps): JSX.Element => { |
||||
|
const [edit, setEdit] = React.useState(false); |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
|
||||
|
const { register, handleSubmit, formState } = useForm<{ |
||||
|
role: Protobuf.Channel_Role; |
||||
|
settings: { |
||||
|
name: string; |
||||
|
}; |
||||
|
}>({ |
||||
|
defaultValues: { |
||||
|
role: channel.role, |
||||
|
settings: { |
||||
|
name: channel.settings?.name, |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const onSubmit = handleSubmit(async (data) => { |
||||
|
setLoading(true); |
||||
|
const adminChannel = Protobuf.Channel.create({ |
||||
|
role: data.role, |
||||
|
index: channel.index, |
||||
|
settings: data.settings, |
||||
|
}); |
||||
|
|
||||
|
await connection.setChannel(adminChannel, (): Promise<void> => { |
||||
|
setLoading(false); |
||||
|
return Promise.resolve(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<div className="relative flex justify-between p-3 bg-gray-100 rounded-md dark:bg-gray-700"> |
||||
|
{edit ? ( |
||||
|
<> |
||||
|
{loading && ( |
||||
|
<div className="absolute top-0 bottom-0 left-0 right-0 z-10 flex rounded-md backdrop-filter backdrop-blur-sm"> |
||||
|
<div className="m-auto text-lg font-medium text-gray-400"> |
||||
|
Loading |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
<div className="my-auto space-x-2"> |
||||
|
<form> |
||||
|
<div className="flex space-x-2"> |
||||
|
<EnumSelect |
||||
|
label="Channel Type" |
||||
|
optionsEnum={Protobuf.Channel_Role} |
||||
|
{...register('role', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Dot |
||||
|
role={channel.role} |
||||
|
admin={channel.settings?.name === 'admin'} |
||||
|
/> |
||||
|
</div> |
||||
|
<Input label="Name" {...register('settings.name')} /> |
||||
|
</form> |
||||
|
</div> |
||||
|
<IconButton |
||||
|
onClick={async (): Promise<void> => { |
||||
|
await onSubmit(); |
||||
|
|
||||
|
setEdit(false); |
||||
|
}} |
||||
|
icon={<FiSave />} |
||||
|
/> |
||||
|
</> |
||||
|
) : ( |
||||
|
<> |
||||
|
<div className="flex my-auto space-x-2"> |
||||
|
<Dot |
||||
|
role={channel.role} |
||||
|
admin={channel.settings?.name === 'admin'} |
||||
|
/> |
||||
|
<div> |
||||
|
{channel.settings?.name.length |
||||
|
? channel.settings.name |
||||
|
: channel.role === Protobuf.Channel_Role.PRIMARY |
||||
|
? 'Primary' |
||||
|
: `Channel: ${channel.index}`} |
||||
|
</div> |
||||
|
</div> |
||||
|
<IconButton |
||||
|
onClick={(): void => { |
||||
|
setEdit(true); |
||||
|
}} |
||||
|
icon={<FiEdit3 />} |
||||
|
/> |
||||
|
</> |
||||
|
)} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,49 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { FiCheck } from 'react-icons/fi'; |
||||
|
|
||||
|
type DefaulButtonProps = JSX.IntrinsicElements['button']; |
||||
|
|
||||
|
export interface IconButtonProps extends DefaulButtonProps { |
||||
|
icon: React.ReactNode; |
||||
|
confirmAction?: () => void; |
||||
|
} |
||||
|
|
||||
|
export const IconButton = ({ |
||||
|
icon, |
||||
|
confirmAction, |
||||
|
...props |
||||
|
}: IconButtonProps): JSX.Element => { |
||||
|
const [hasConfirmed, setHasConfirmed] = React.useState(false); |
||||
|
|
||||
|
const handleConfirm = (): void => { |
||||
|
if (confirmAction) { |
||||
|
if (hasConfirmed) { |
||||
|
void confirmAction(); |
||||
|
} |
||||
|
setHasConfirmed(true); |
||||
|
setTimeout(() => { |
||||
|
setHasConfirmed(false); |
||||
|
}, 3000); |
||||
|
} |
||||
|
}; |
||||
|
return ( |
||||
|
<div |
||||
|
className="my-auto text-gray-500 dark:text-gray-400" |
||||
|
onClick={handleConfirm} |
||||
|
> |
||||
|
<button |
||||
|
type="button" |
||||
|
className={`p-2 rounded-md active:scale-95 ${ |
||||
|
hasConfirmed |
||||
|
? 'bg-red-500' |
||||
|
: 'hover:bg-gray-200 dark:hover:bg-gray-600' |
||||
|
}`}
|
||||
|
{...props} |
||||
|
> |
||||
|
{hasConfirmed ? <FiCheck /> : icon} |
||||
|
<span className="sr-only">Refresh</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -1,61 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
type DefaultInputProps = JSX.IntrinsicElements['input']; |
|
||||
|
|
||||
interface InputProps extends DefaultInputProps { |
|
||||
icon?: JSX.Element; |
|
||||
label?: string; |
|
||||
valid?: boolean; |
|
||||
validationMessage?: string; |
|
||||
} |
|
||||
|
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>( |
|
||||
function Input( |
|
||||
{ |
|
||||
icon, |
|
||||
label, |
|
||||
valid, |
|
||||
validationMessage, |
|
||||
id, |
|
||||
disabled, |
|
||||
...props |
|
||||
}: InputProps, |
|
||||
ref, |
|
||||
) { |
|
||||
return ( |
|
||||
<div className="w-full"> |
|
||||
<label |
|
||||
htmlFor={id} |
|
||||
className="block text-sm font-medium text-black dark:text-white" |
|
||||
> |
|
||||
{label} |
|
||||
</label> |
|
||||
<div className="relative"> |
|
||||
{icon && ( |
|
||||
<div className="absolute inset-y-0 left-0 flex items-center px-3 pointer-events-none"> |
|
||||
{React.cloneElement(icon, { |
|
||||
className: 'w-5 h-5 text-gray-500 dark:text-gray-600', |
|
||||
})} |
|
||||
</div> |
|
||||
)} |
|
||||
<input |
|
||||
id={id} |
|
||||
ref={ref} |
|
||||
disabled={disabled} |
|
||||
{...props} |
|
||||
className={`block w-full h-11 rounded-md border shadow-sm focus:outline-none focus:border-primary dark:focus:border-primary dark:border-gray-600 dark:text-white ${ |
|
||||
icon ? 'pl-9' : 'pl-2' |
|
||||
} ${ |
|
||||
disabled |
|
||||
? 'bg-gray-200 dark:bg-primaryDark cursor-not-allowed' |
|
||||
: 'bg-white dark:bg-secondaryDark' |
|
||||
}`}
|
|
||||
/> |
|
||||
</div> |
|
||||
{!valid && ( |
|
||||
<div className="text-sm text-gray-600">{validationMessage}</div> |
|
||||
)} |
|
||||
</div> |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
@ -0,0 +1,58 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { InputWrapper } from './InputWrapper.jsx'; |
||||
|
import { Label } from './Label.jsx'; |
||||
|
|
||||
|
type DefaultSelectProps = JSX.IntrinsicElements['select']; |
||||
|
|
||||
|
interface SelectProps extends DefaultSelectProps { |
||||
|
options?: { |
||||
|
name: string; |
||||
|
value: number | string; |
||||
|
}[]; |
||||
|
optionsEnum?: { [s: string]: string | number }; |
||||
|
label?: string; |
||||
|
error?: string; |
||||
|
small?: boolean; |
||||
|
} |
||||
|
|
||||
|
export const EnumSelect = React.forwardRef<HTMLSelectElement, SelectProps>( |
||||
|
({ options, optionsEnum, label, error, small, ...props }, ref) => { |
||||
|
const optionsEnumValues = optionsEnum |
||||
|
? Object.entries(optionsEnum).filter( |
||||
|
(value) => typeof value[1] === 'number', |
||||
|
) |
||||
|
: []; |
||||
|
return ( |
||||
|
<div> |
||||
|
{label && <Label label={label} error={error} />} |
||||
|
<InputWrapper> |
||||
|
<select |
||||
|
ref={ref} |
||||
|
className={`w-full bg-transparent focus:outline-none focus:border-primary ${ |
||||
|
small ? 'py-1 mx-1' : 'h-10 mx-2' |
||||
|
}`}
|
||||
|
{...props} |
||||
|
> |
||||
|
{optionsEnumValues.length && |
||||
|
optionsEnumValues.map(([name, value]) => ( |
||||
|
<option className="dark:bg-gray-700" key={value} value={value}> |
||||
|
{name} |
||||
|
</option> |
||||
|
))} |
||||
|
{options && |
||||
|
options.map((option) => ( |
||||
|
<option |
||||
|
className="dark:bg-gray-700" |
||||
|
key={option.value} |
||||
|
value={option.value} |
||||
|
> |
||||
|
{option.name} |
||||
|
</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</InputWrapper> |
||||
|
</div> |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
@ -0,0 +1,30 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { InputWrapper } from './InputWrapper.jsx'; |
||||
|
import { Label } from './Label.jsx'; |
||||
|
|
||||
|
type DefaultInputProps = JSX.IntrinsicElements['input']; |
||||
|
|
||||
|
interface InputProps extends DefaultInputProps { |
||||
|
label?: string; |
||||
|
error?: string; |
||||
|
action?: JSX.Element; |
||||
|
} |
||||
|
|
||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>( |
||||
|
function Input({ label, error, action, ...props }: InputProps, ref) { |
||||
|
return ( |
||||
|
<div className="w-full"> |
||||
|
{label && <Label label={label} error={error} />} |
||||
|
<InputWrapper error={error} disabled={props.disabled}> |
||||
|
<input |
||||
|
ref={ref} |
||||
|
className="w-full h-10 px-3 py-2 bg-transparent focus:outline-none focus:border-primary" |
||||
|
{...props} |
||||
|
/> |
||||
|
{action && <div className="flex mr-1">{action}</div>} |
||||
|
</InputWrapper> |
||||
|
</div> |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
@ -0,0 +1,29 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
export interface LabelProps { |
||||
|
error?: string; |
||||
|
disabled?: boolean; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const InputWrapper = ({ |
||||
|
error, |
||||
|
disabled, |
||||
|
children, |
||||
|
}: LabelProps): JSX.Element => ( |
||||
|
<div |
||||
|
className={`flex w-full border-y border rounded-md ${ |
||||
|
disabled |
||||
|
? 'bg-gray-200 text-gray-500 dark:bg-gray-800 dark:text-gray-400 border-gray-400' |
||||
|
: '' |
||||
|
} ${ |
||||
|
error |
||||
|
? 'border-red-500' |
||||
|
: disabled |
||||
|
? 'border-gray-200' |
||||
|
: ' focus-within:border-primary hover:border-primary' |
||||
|
}`}
|
||||
|
> |
||||
|
{children} |
||||
|
</div> |
||||
|
); |
||||
@ -0,0 +1,13 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
export interface LabelProps { |
||||
|
label: string; |
||||
|
error?: string; |
||||
|
} |
||||
|
|
||||
|
export const Label = ({ label, error }: LabelProps): JSX.Element => ( |
||||
|
<label className="text-xs font-semibold text-gray-500"> |
||||
|
{label} |
||||
|
{error && <span className="ml-2 text-red-500">{error}</span>} |
||||
|
</label> |
||||
|
); |
||||
Loading…
Reference in new issue