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