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 = { |
module.exports = { |
||||
plugins: { |
plugins: { |
||||
tailwindcss: {}, |
tailwindcss: {}, |
||||
autoprefixer: {}, |
autoprefixer: {} |
||||
}, |
} |
||||
} |
}; |
||||
|
|||||
@ -1,3 +1,4 @@ |
|||||
module.exports = { |
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 => { |
export const bitwiseEncode = (enumValues: number[]): number => { |
||||
return enumValues.reduce((acc, curr) => acc | curr, 0); |
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); |
const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean); |
||||
|
|
||||
return enumValues.map((b) => value & b).filter(Boolean); |
return enumValues.map((b) => value & b).filter(Boolean); |
||||
}; |
}; |
||||
|
|||||
@ -1,3 +1,3 @@ |
|||||
@tailwind base; |
@tailwind base; |
||||
@tailwind components; |
@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