79 changed files with 2085 additions and 1269 deletions
@ -0,0 +1,4 @@ |
|||
# Ignore artifacts: |
|||
dist |
|||
pnpm-lock.yaml |
|||
stats.html |
|||
File diff suppressed because it is too large
@ -1,6 +1,6 @@ |
|||
module.exports = { |
|||
plugins: { |
|||
tailwindcss: {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
} |
|||
autoprefixer: {} |
|||
} |
|||
}; |
|||
|
|||
@ -1,3 +1,4 @@ |
|||
module.exports = { |
|||
plugins: [require('prettier-plugin-tailwindcss')], |
|||
} |
|||
trailingComma: "none", |
|||
plugins: [require("prettier-plugin-tailwindcss")] |
|||
}; |
|||
|
|||
@ -0,0 +1,126 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { fromByteArray } from "base64-js"; |
|||
import { toast } from "react-hot-toast"; |
|||
import { QRCode } from "react-qrcode-logo"; |
|||
|
|||
import { Dialog } from "@headlessui/react"; |
|||
import { ClipboardIcon, XMarkIcon } from "@heroicons/react/24/outline"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
import { Checkbox } from "../form/Checkbox.js"; |
|||
import { Input } from "../form/Input.js"; |
|||
import { IconButton } from "../IconButton.js"; |
|||
|
|||
export interface ImportDialogProps { |
|||
isOpen: boolean; |
|||
close: () => void; |
|||
loraConfig?: Protobuf.Config_LoRaConfig; |
|||
channels: Protobuf.Channel[]; |
|||
} |
|||
|
|||
export const ImportDialog = ({ |
|||
isOpen, |
|||
close, |
|||
loraConfig, |
|||
channels |
|||
}: ImportDialogProps): JSX.Element => { |
|||
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]); |
|||
const [QRCodeURL, setQRCodeURL] = useState<string>(""); |
|||
|
|||
useEffect(() => { |
|||
const channelsToEncode = channels |
|||
.filter((channel) => selectedChannels.includes(channel.index)) |
|||
.map((channel) => channel.settings) |
|||
.filter((ch): ch is Protobuf.ChannelSettings => !!ch); |
|||
const encoded = Protobuf.ChannelSet.toBinary({ |
|||
loraConfig, |
|||
settings: channelsToEncode |
|||
}); |
|||
const base64 = fromByteArray(encoded) |
|||
.replace(/=/g, "") |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_"); |
|||
|
|||
setQRCodeURL(`https://meshtastic.org/e/#${base64}`); |
|||
}, [channels, selectedChannels, loraConfig]); |
|||
|
|||
return ( |
|||
<Dialog open={isOpen} onClose={close}> |
|||
<div className="fixed inset-0 bg-black/30" aria-hidden="true" /> |
|||
<div className="fixed inset-0 flex items-center justify-center p-4"> |
|||
<Dialog.Panel> |
|||
<div className="divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow"> |
|||
<div className="flex px-4 py-5 sm:px-6"> |
|||
<div> |
|||
<h1 className="text-lg font-bold">Generate QR Code</h1> |
|||
<h5 className="text-sm text-slate-600"> |
|||
The current LoRa configuration will also be shared. |
|||
</h5> |
|||
</div> |
|||
<IconButton |
|||
onClick={close} |
|||
className="my-auto ml-auto" |
|||
size="sm" |
|||
variant="secondary" |
|||
icon={<XMarkIcon className="h-4" />} |
|||
/> |
|||
</div> |
|||
<div className="flex gap-3 px-4 py-5 sm:p-6"> |
|||
<div className="flex w-40 flex-col gap-1"> |
|||
{channels.map((channel) => ( |
|||
<Checkbox |
|||
key={channel.index} |
|||
disabled={ |
|||
channel.index === 0 || |
|||
channel.role === Protobuf.Channel_Role.DISABLED |
|||
} |
|||
label={ |
|||
channel.settings?.name.length |
|||
? channel.settings.name |
|||
: channel.role === Protobuf.Channel_Role.PRIMARY |
|||
? "Primary" |
|||
: `Channel: ${channel.index}` |
|||
} |
|||
checked={selectedChannels.includes(channel.index)} |
|||
onChange={() => { |
|||
if (selectedChannels.includes(channel.index)) { |
|||
setSelectedChannels( |
|||
selectedChannels.filter((c) => c !== channel.index) |
|||
); |
|||
} else { |
|||
setSelectedChannels([ |
|||
...selectedChannels, |
|||
channel.index |
|||
]); |
|||
} |
|||
}} |
|||
/> |
|||
))} |
|||
</div> |
|||
<QRCode value={QRCodeURL} size={200} qrStyle="dots" /> |
|||
</div> |
|||
|
|||
<div className="px-4 py-4 sm:px-6"> |
|||
<Input |
|||
label="Sharable URL" |
|||
value={QRCodeURL} |
|||
disabled |
|||
action={{ |
|||
icon: <ClipboardIcon className="h-4" />, |
|||
action() { |
|||
void navigator.clipboard.writeText(QRCodeURL); |
|||
toast.success("Copied URL to Clipboard"); |
|||
} |
|||
}} |
|||
/> |
|||
</div> |
|||
|
|||
{/* </Card> */} |
|||
</div> |
|||
</Dialog.Panel> |
|||
</div> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,50 @@ |
|||
import type React from "react"; |
|||
import { useState } from "react"; |
|||
|
|||
import { useDevice } from "@app/core/providers/useDevice.js"; |
|||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; |
|||
|
|||
export const Drawer = (): JSX.Element => { |
|||
const [drawerOpen, setDrawerOpen] = useState(false); |
|||
|
|||
const tabs = [{ title: "Notifications" }, { title: "Debug" }]; |
|||
|
|||
const { config, moduleConfig, hardware, nodes, waypoints, connection } = |
|||
useDevice(); |
|||
|
|||
const [serialLogs, setSerialLogs] = useState<string>(""); |
|||
|
|||
connection?.onDeviceDebugLog.subscribe((packet) => { |
|||
setSerialLogs(serialLogs + new TextDecoder().decode(packet)); |
|||
}); |
|||
|
|||
return ( |
|||
<div className={`shadow-md ${drawerOpen ? "h-40" : "h-8"}`}> |
|||
<div className="flex h-8 bg-slate-50"> |
|||
<div |
|||
onClick={() => { |
|||
setDrawerOpen(!drawerOpen); |
|||
}} |
|||
className="ml-auto flex px-2 hover:cursor-pointer hover:bg-slate-100" |
|||
> |
|||
<div className="m-auto"> |
|||
{drawerOpen ? ( |
|||
<ChevronDownIcon className="h-4 text-gray-700" /> |
|||
) : ( |
|||
<ChevronUpIcon className="h-4 text-gray-700" /> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className={`${drawerOpen ? "flex" : "hidden"}`}> |
|||
<div> |
|||
{serialLogs.split("\n").map((line, index) => ( |
|||
<div key={index} className="text-sm"> |
|||
{line} |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,132 @@ |
|||
import type React from "react"; |
|||
|
|||
import { Controller, useFieldArray, useForm } from "react-hook-form"; |
|||
|
|||
import { Button } from "@app/components/Button.js"; |
|||
import { InfoWrapper } from "@app/components/form/InfoWrapper.js"; |
|||
import { Input } from "@app/components/form/Input.js"; |
|||
import { Toggle } from "@app/components/form/Toggle.js"; |
|||
import { IconButton } from "@app/components/IconButton.js"; |
|||
import { useAppStore } from "@app/core/stores/appStore.js"; |
|||
import { MapValidation } from "@app/validation/appConfig/map.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { TrashIcon } from "@heroicons/react/24/outline"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
|
|||
export const Map = (): JSX.Element => { |
|||
const { rasterSources, setRasterSources } = useAppStore(); |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
control, |
|||
reset |
|||
} = useForm<MapValidation>({ |
|||
defaultValues: { |
|||
// wmsSources: wmsSources ?? [
|
|||
// {
|
|||
// url: "",
|
|||
// tileSize: 512,
|
|||
// type: "raster"
|
|||
// }
|
|||
// ]
|
|||
}, |
|||
resolver: classValidatorResolver(MapValidation) |
|||
}); |
|||
|
|||
const { fields, append, remove, insert } = useFieldArray({ |
|||
control, |
|||
name: "rasterSources" |
|||
}); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setRasterSources(data.rasterSources); |
|||
}); |
|||
|
|||
// useEffect(() => {
|
|||
// reset(rasterSources);
|
|||
// }, [reset, rasterSources]);
|
|||
|
|||
return ( |
|||
<Form |
|||
title="Map Config" |
|||
breadcrumbs={["App Config", "Map"]} |
|||
reset={() => |
|||
reset({ |
|||
rasterSources |
|||
}) |
|||
} |
|||
dirty={isDirty} |
|||
onSubmit={onSubmit} |
|||
> |
|||
<InfoWrapper label="WMS Sources"> |
|||
<div className="flex flex-col gap-2"> |
|||
{fields.map((field, index) => ( |
|||
<div key={field.id} className="flex w-full gap-2"> |
|||
<Controller |
|||
name={`rasterSources.${index}.enabled`} |
|||
control={control} |
|||
render={({ field: { value, ...rest } }) => ( |
|||
<Toggle checked={value} {...rest} /> |
|||
)} |
|||
/> |
|||
<Input |
|||
placeholder="Name" |
|||
error={ |
|||
errors.rasterSources |
|||
? errors.rasterSources[index]?.title?.message |
|||
: undefined |
|||
} |
|||
{...register(`rasterSources.${index}.title`)} |
|||
/> |
|||
<Input |
|||
placeholder="Tile Size" |
|||
type="number" |
|||
error={ |
|||
errors.rasterSources |
|||
? errors.rasterSources[index]?.tileSize?.message |
|||
: undefined |
|||
} |
|||
{...register(`rasterSources.${index}.tileSize`, { |
|||
valueAsNumber: true |
|||
})} |
|||
/> |
|||
<Input |
|||
placeholder="URL" |
|||
error={ |
|||
errors.rasterSources |
|||
? errors.rasterSources[index]?.tiles?.message |
|||
: undefined |
|||
} |
|||
{...register(`rasterSources.${index}.tiles`)} |
|||
/> |
|||
<IconButton |
|||
className="shrink-0" |
|||
icon={<TrashIcon className="w-4" />} |
|||
onClick={() => { |
|||
remove(index); |
|||
}} |
|||
/> |
|||
</div> |
|||
))} |
|||
<Button |
|||
variant="secondary" |
|||
onClick={() => { |
|||
append({ |
|||
enabled: true, |
|||
title: "", |
|||
tiles: [ |
|||
"https://img.nj.gov/imagerywms/Natural2015?bbox={bbox-epsg-3857}&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:3857&transparent=true&width=256&height=256&layers=Natural2015" |
|||
], |
|||
tileSize: 512 |
|||
}); |
|||
}} |
|||
> |
|||
New Source |
|||
</Button> |
|||
</div> |
|||
</InfoWrapper> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,82 @@ |
|||
import React, { useState } from "react"; |
|||
|
|||
import { |
|||
bitwiseDecode, |
|||
bitwiseEncode, |
|||
enumLike |
|||
} from "@app/core/utils/bitwise.js"; |
|||
import { Listbox } from "@headlessui/react"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
import { InfoWrapper } from "./InfoWrapper.js"; |
|||
|
|||
export interface BitwiseSelectProps { |
|||
label?: string; |
|||
description?: string; |
|||
error?: string; |
|||
selected: number; |
|||
decodeEnun: enumLike; |
|||
onChange: (value: number) => void; |
|||
} |
|||
|
|||
export const BitwiseSelect = ({ |
|||
label, |
|||
description, |
|||
error, |
|||
selected, |
|||
decodeEnun, |
|||
onChange |
|||
}: BitwiseSelectProps): JSX.Element => { |
|||
const [decodedSelected, setDecodedSelected] = useState<string[]>([]); |
|||
|
|||
const options = Object.entries(decodeEnun) |
|||
.filter((value) => typeof value[1] !== "number") |
|||
.map((value) => { |
|||
return { |
|||
value: parseInt(value[0]), |
|||
label: value[1] |
|||
.toString() |
|||
.replace("POS_", "") |
|||
.toLowerCase() |
|||
.toLocaleUpperCase() //TODO: Investigate
|
|||
}; |
|||
}); |
|||
|
|||
React.useEffect(() => { |
|||
setDecodedSelected( |
|||
bitwiseDecode(selected, Protobuf.Config_PositionConfig_PositionFlags).map( |
|||
(flag) => |
|||
Protobuf.Config_PositionConfig_PositionFlags[flag] |
|||
.replace("POS_", "") |
|||
.toLowerCase() |
|||
) |
|||
); |
|||
}, [selected]); |
|||
|
|||
return ( |
|||
<InfoWrapper label={label} description={description} error={error}> |
|||
<Listbox |
|||
value={bitwiseDecode(selected, decodeEnun)} |
|||
onChange={(value) => { |
|||
onChange(bitwiseEncode(value)); |
|||
}} |
|||
multiple |
|||
> |
|||
<Listbox.Button |
|||
className={`flex h-10 w-full items-center gap-2 rounded-md border-transparent bg-orange-100 px-3 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500`} |
|||
> |
|||
{decodedSelected.map((option) => ( |
|||
<span className="rounded-md bg-orange-300 p-1">{option}</span> |
|||
))} |
|||
</Listbox.Button> |
|||
<Listbox.Options> |
|||
{options.map((option) => ( |
|||
<Listbox.Option key={option.value} value={option.value}> |
|||
{option.label} |
|||
</Listbox.Option> |
|||
))} |
|||
</Listbox.Options> |
|||
</Listbox> |
|||
</InfoWrapper> |
|||
); |
|||
}; |
|||
@ -0,0 +1,133 @@ |
|||
import React, { InputHTMLAttributes, useState } from "react"; |
|||
|
|||
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; |
|||
|
|||
export interface IPAddressProps extends InputHTMLAttributes<HTMLInputElement> { |
|||
label: string; |
|||
description?: string; |
|||
prefix?: string; |
|||
suffix?: string; |
|||
action?: { |
|||
icon: JSX.Element; |
|||
action: () => void; |
|||
}; |
|||
error?: string; |
|||
} |
|||
|
|||
export const IPAddress = ({ |
|||
label, |
|||
description, |
|||
action, |
|||
error, |
|||
disabled, |
|||
...rest |
|||
}: IPAddressProps): JSX.Element => { |
|||
const [value, setValue] = useState<[number, number, number, number]>([ |
|||
0, 0, 0, 0 |
|||
]); |
|||
|
|||
// const getRange = (el) => {
|
|||
// var cuRange, tbRange, headRange, range, dupRange, ret = {};
|
|||
// if (el.setSelectionRange) {
|
|||
// // standard
|
|||
// ret.begin = el.selectionStart;
|
|||
// ret.end = el.selectionEnd;
|
|||
// ret.result = el.value.substring(ret.begin, ret.end);
|
|||
// } else if (document.selection) {
|
|||
// // ie
|
|||
// if (el.tagName.toLowerCase() === 'input') {
|
|||
// cuRange = document.selection.createRange();
|
|||
// tbRange = el.createTextRange();
|
|||
// tbRange.collapse(true);
|
|||
// tbRange.select();
|
|||
// headRange = document.selection.createRange();
|
|||
// headRange.setEndPoint('EndToEnd', cuRange);
|
|||
// ret.begin = headRange.text.length - cuRange.text.length;
|
|||
// ret.end = headRange.text.length;
|
|||
// ret.result = cuRange.text;
|
|||
// cuRange.select();
|
|||
// } else if (el.tagName.toLowerCase() === 'textarea') {
|
|||
// range = document.selection.createRange();
|
|||
// dupRange = range.duplicate();
|
|||
// dupRange.moveToElementText(el);
|
|||
// dupRange.setEndPoint('EndToEnd', range);
|
|||
// ret.begin = dupRange.text.length - range.text.length;
|
|||
// ret.end = dupRange.text.length;
|
|||
// ret.result = range.text;
|
|||
// }
|
|||
// }
|
|||
// el.focus();
|
|||
// return ret;
|
|||
// }
|
|||
|
|||
// const isValidIPItemValue = (val) => {
|
|||
// val = parseInt(val);
|
|||
// return !isNaN(val) && val >= 0 && val <= 255;
|
|||
// }
|
|||
|
|||
// const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
|||
// /* 37 = ←, 39 = →, 8 = backspace, 110 or 190 = . */
|
|||
// let domId = index;
|
|||
// if ((event.keyCode === 37 || event.keyCode === 8) && getRange(event.target).end === 0 && index > 0) { domId = index - 1; }
|
|||
// if (event.keyCode === 39 && getRange(event.target).end === event.target.value.length && index < 3) { domId = index + 1; }
|
|||
// if (event.keyCode === 110 || event.keyCode === 190) {
|
|||
// event.preventDefault();
|
|||
// if(i < 3) {
|
|||
// domId = i + 1;
|
|||
// }
|
|||
// }
|
|||
// this[`_input-${domId}`].focus();
|
|||
// }
|
|||
|
|||
// useEffect(() => {
|
|||
|
|||
// }, [])
|
|||
|
|||
// const ip = value.map(val => isNaN(val) ? '' : val).join('.');
|
|||
|
|||
return ( |
|||
<div> |
|||
{/* Label */} |
|||
<label className="block text-sm font-medium text-gray-700">{label}</label> |
|||
{/* */} |
|||
<div className="relative flex gap-1 rounded-md"> |
|||
{value.map((octet, index) => ( |
|||
<> |
|||
<input |
|||
key={index} |
|||
// ref={ref}
|
|||
className={`flex h-10 w-full rounded-md border-transparent bg-orange-100 px-3 text-sm shadow-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${ |
|||
action ? "rounded-r-none" : "" |
|||
} ${ |
|||
disabled |
|||
? "cursor-not-allowed bg-orange-50 text-orange-200" |
|||
: "" |
|||
}`}
|
|||
disabled={disabled} |
|||
{...rest} |
|||
/> |
|||
{index !== 3 && <i className="text-xl">.</i>} |
|||
</> |
|||
))} |
|||
{action && ( |
|||
<button |
|||
type="button" |
|||
onClick={action.action} |
|||
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md bg-orange-200 px-4 py-2 text-sm font-medium hover:bg-orange-300 focus:outline-none focus:ring-2 focus:ring-orange-500" |
|||
> |
|||
{action.icon} |
|||
</button> |
|||
)} |
|||
{error && ( |
|||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> |
|||
<ExclamationCircleIcon className="h-5 w-5 text-red-500" /> |
|||
</div> |
|||
)} |
|||
</div> |
|||
{description && ( |
|||
<p className="mt-2 text-sm text-gray-500">{description}</p> |
|||
)} |
|||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>} |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,39 @@ |
|||
import type React from "react"; |
|||
|
|||
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; |
|||
|
|||
export interface InfoWrapperProps { |
|||
label?: string; |
|||
description?: string; |
|||
error?: string; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const InfoWrapper = ({ |
|||
label, |
|||
description, |
|||
error, |
|||
children |
|||
}: InfoWrapperProps): JSX.Element => { |
|||
return ( |
|||
<div className="w-full"> |
|||
{/* Label */} |
|||
{label && ( |
|||
<label className="block text-sm font-medium text-gray-700"> |
|||
{label} |
|||
</label> |
|||
)} |
|||
{/* */} |
|||
{children} |
|||
{error && ( |
|||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> |
|||
<ExclamationCircleIcon className="h-5 w-5 text-red-500" /> |
|||
</div> |
|||
)} |
|||
{description && ( |
|||
<p className="mt-2 text-sm text-gray-500">{description}</p> |
|||
)} |
|||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>} |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,9 +1,15 @@ |
|||
export interface enumLike { |
|||
[key: number]: string | number; |
|||
} |
|||
|
|||
export const bitwiseEncode = (enumValues: number[]): number => { |
|||
return enumValues.reduce((acc, curr) => acc | curr, 0); |
|||
}; |
|||
|
|||
export const bitwiseDecode = (value: number, decodeEnum: object): number[] => { |
|||
export const bitwiseDecode = ( |
|||
value: number, |
|||
decodeEnum: enumLike |
|||
): number[] => { |
|||
const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean); |
|||
|
|||
return enumValues.map((b) => value & b).filter(Boolean); |
|||
}; |
|||
|
|||
@ -1,3 +1,3 @@ |
|||
@tailwind base; |
|||
@tailwind components; |
|||
@tailwind utilities; |
|||
@tailwind utilities; |
|||
|
|||
@ -0,0 +1,22 @@ |
|||
import { IsArray, IsBoolean, IsNumber, IsString } from "class-validator"; |
|||
|
|||
import type { RasterSource } from "@app/core/stores/appStore.js"; |
|||
|
|||
export class MapValidation { |
|||
@IsArray() |
|||
rasterSources: MapValidation_RasterSources[]; |
|||
} |
|||
|
|||
export class MapValidation_RasterSources implements RasterSource { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsString() |
|||
title: string; |
|||
|
|||
// @IsUrl()
|
|||
tiles: string[]; |
|||
|
|||
@IsNumber() |
|||
tileSize: number; |
|||
} |
|||
Loading…
Reference in new issue