Merge branch 'master' into feat/update-tailwind
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
name: Code Formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Biome
|
||||
run: npm install --global @biomejs/biome
|
||||
|
||||
- name: Format with Biome
|
||||
run: biome format --write .
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: format code"
|
||||
git push
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"description": "Meshtastic web client",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "pnpm check && rsbuild build",
|
||||
"build": "rsbuild build",
|
||||
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build",
|
||||
"check": "biome check src/",
|
||||
"check:fix": "pnpm check --write src/",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select";
|
||||
selectChange?: (e: string) => void;
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
@@ -40,7 +41,10 @@ export function SelectInput<T extends FieldValues>({
|
||||
: [];
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(e) => onChange(Number.parseInt(e))}
|
||||
onValueChange={(e) => {
|
||||
if (field.selectChange) field.selectChange(e);
|
||||
onChange(Number.parseInt(e));
|
||||
}}
|
||||
disabled={disabled}
|
||||
value={value?.toString()}
|
||||
{...remainingProperties}
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Bluetooth = (): JSX.Element => {
|
||||
export const Bluetooth = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
const [bluetoothValidationText, setBluetoothValidationText] =
|
||||
useState<string>();
|
||||
const {
|
||||
hasErrors,
|
||||
getErrorMessage,
|
||||
hasFieldError,
|
||||
addError,
|
||||
removeError,
|
||||
clearErrors,
|
||||
} = useAppStore();
|
||||
|
||||
const [bluetoothPin, setBluetoothPin] = useState(
|
||||
config?.bluetooth?.fixedPin.toString() ?? "",
|
||||
);
|
||||
|
||||
const validateBluetoothPin = (pin: string) => {
|
||||
// if empty show error they need a pin set
|
||||
if (pin === "") {
|
||||
return addError("fixedPin", "Bluetooth Pin is required");
|
||||
}
|
||||
|
||||
// clear any existing errors
|
||||
clearErrors();
|
||||
|
||||
// if it starts with 0 show error
|
||||
if (pin[0] === "0") {
|
||||
return addError("fixedPin", "Bluetooth Pin cannot start with 0");
|
||||
}
|
||||
// if it's not 6 digits show error
|
||||
if (pin.length < 6) {
|
||||
return addError("fixedPin", "Pin must be 6 digits");
|
||||
}
|
||||
|
||||
removeError("fixedPin");
|
||||
};
|
||||
|
||||
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value[0] === "0") {
|
||||
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
|
||||
} else {
|
||||
setBluetoothValidationText("");
|
||||
}
|
||||
const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
setBluetoothPin(numericValue);
|
||||
validateBluetoothPin(numericValue);
|
||||
};
|
||||
|
||||
const onSubmit = (data: BluetoothValidation) => {
|
||||
if (hasErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
payloadVariant: {
|
||||
@@ -48,6 +82,12 @@ export const Bluetooth = (): JSX.Element => {
|
||||
name: "mode",
|
||||
label: "Pairing mode",
|
||||
description: "Pin selection behaviour.",
|
||||
selectChange: (e) => {
|
||||
if (e !== "1") {
|
||||
setBluetoothPin("");
|
||||
removeError("fixedPin");
|
||||
}
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
@@ -63,7 +103,9 @@ export const Bluetooth = (): JSX.Element => {
|
||||
name: "fixedPin",
|
||||
label: "Pin",
|
||||
description: "Pin to use when pairing",
|
||||
validationText: bluetoothValidationText,
|
||||
validationText: hasFieldError("fixedPin")
|
||||
? getErrorMessage("fixedPin")
|
||||
: "",
|
||||
inputChange: bluetoothPinChangeEvent,
|
||||
disabledBy: [
|
||||
{
|
||||
@@ -77,7 +119,9 @@ export const Bluetooth = (): JSX.Element => {
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {},
|
||||
properties: {
|
||||
value: bluetoothPin,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const [https, setHTTPS] = useState(false);
|
||||
|
||||
@@ -69,7 +68,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
<>
|
||||
<Label>Use HTTPS</Label>
|
||||
<Switch
|
||||
onCheckedChange={(checked) => {checked ? setHTTPS(true) : setHTTPS(false) }}
|
||||
onCheckedChange={(checked) => {
|
||||
checked ? setHTTPS(true) : setHTTPS(false);
|
||||
}}
|
||||
// label="Use TLS"
|
||||
// description="Description"
|
||||
disabled={
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
|
||||
import Footer from "./UI/Footer";
|
||||
import { Spinner } from "./UI/Spinner";
|
||||
|
||||
export interface PageLayoutProps {
|
||||
label: string;
|
||||
@@ -10,6 +11,8 @@ export interface PageLayoutProps {
|
||||
icon: LucideIcon;
|
||||
iconClasses?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -18,7 +21,7 @@ export const PageLayout = ({
|
||||
noPadding,
|
||||
actions,
|
||||
children,
|
||||
}: PageLayoutProps): JSX.Element => {
|
||||
}: PageLayoutProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
@@ -33,14 +36,22 @@ export const PageLayout = ({
|
||||
<div className="flex w-full items-center">
|
||||
<span className="w-full text-lg font-medium">{label}</span>
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
{actions?.map((action) => (
|
||||
<button
|
||||
key={action.icon.displayName}
|
||||
type="button"
|
||||
disabled={action?.disabled}
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon className={action.iconClasses} />
|
||||
{action?.isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<action.icon
|
||||
className={action.iconClasses}
|
||||
aria-disabled={action.disabled}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
|
||||
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-8 w-8",
|
||||
lg: "h-12 w-12",
|
||||
};
|
||||
|
||||
export function Spinner({ className, size = "md", ...props }: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
aria-label="Loading..."
|
||||
className={cn(
|
||||
"flex items-center justify-center fade-in-50 fade-out-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
className={cn("animate-spin-slow stroke-current", sizeClasses[size])}
|
||||
role="img"
|
||||
aria-label="Loading spinner"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<line
|
||||
x1="128"
|
||||
y1="32"
|
||||
x2="128"
|
||||
y2="64"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="195.9"
|
||||
y1="60.1"
|
||||
x2="173.3"
|
||||
y2="82.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="224"
|
||||
y1="128"
|
||||
x2="192"
|
||||
y2="128"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="195.9"
|
||||
y1="195.9"
|
||||
x2="173.3"
|
||||
y2="173.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="128"
|
||||
y1="224"
|
||||
x2="128"
|
||||
y2="192"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="60.1"
|
||||
y1="195.9"
|
||||
x2="82.7"
|
||||
y2="173.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="32"
|
||||
y1="128"
|
||||
x2="64"
|
||||
y2="128"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="60.1"
|
||||
y1="60.1"
|
||||
x2="82.7"
|
||||
y2="82.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,11 @@ export interface RasterSource {
|
||||
tileSize: number;
|
||||
}
|
||||
|
||||
interface ErrorState {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
selectedDevice: number;
|
||||
devices: {
|
||||
@@ -22,11 +27,11 @@ interface AppState {
|
||||
nodeNumDetails: number;
|
||||
activeChat: number;
|
||||
chatType: "broadcast" | "direct";
|
||||
errors: ErrorState[];
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
addRasterSource: (source: RasterSource) => void;
|
||||
removeRasterSource: (index: number) => void;
|
||||
|
||||
setSelectedDevice: (deviceId: number) => void;
|
||||
addDevice: (device: { id: number; num: number }) => void;
|
||||
removeDevice: (deviceId: number) => void;
|
||||
@@ -36,9 +41,18 @@ interface AppState {
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: "broadcast" | "direct") => void;
|
||||
|
||||
// Error management
|
||||
hasErrors: () => boolean;
|
||||
getErrorMessage: (field: string) => string | undefined;
|
||||
hasFieldError: (field: string) => boolean;
|
||||
addError: (field: string, message: string) => void;
|
||||
removeError: (field: string) => void;
|
||||
clearErrors: () => void;
|
||||
setNewErrors: (newErrors: ErrorState[]) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set) => ({
|
||||
export const useAppStore = create<AppState>()((set, get) => ({
|
||||
selectedDevice: 0,
|
||||
devices: [],
|
||||
currentPage: "messages",
|
||||
@@ -49,6 +63,7 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
nodeNumDetails: 0,
|
||||
activeChat: Types.ChannelNumber.Primary,
|
||||
chatType: "broadcast",
|
||||
errors: [],
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => {
|
||||
set(
|
||||
@@ -114,4 +129,47 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
set(() => ({
|
||||
chatType: type,
|
||||
})),
|
||||
hasErrors: () => {
|
||||
const state = get();
|
||||
return state.errors.length > 0;
|
||||
},
|
||||
getErrorMessage: (field: string) => {
|
||||
const state = get();
|
||||
return state.errors.find((err) => err.field === field)?.message;
|
||||
},
|
||||
hasFieldError: (field: string) => {
|
||||
const state = get();
|
||||
return state.errors.some((err) => err.field === field);
|
||||
},
|
||||
addError: (field: string, message: string) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = [
|
||||
...draft.errors.filter((e) => e.field !== field),
|
||||
{ field, message },
|
||||
];
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeError: (field: string) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = draft.errors.filter((e) => e.field !== field);
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearErrors: () => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = [];
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNewErrors: (newErrors: ErrorState[]) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = newErrors;
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -93,3 +93,13 @@
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
@keyframes spin-slower {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slower 2s linear infinite;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
|
||||
export const DeviceConfig = (): JSX.Element => {
|
||||
export const DeviceConfig = () => {
|
||||
const { metadata } = useDevice();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Device",
|
||||
|
||||
+55
-26
@@ -1,3 +1,4 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { useDevice } from "@app/core/stores/deviceStore.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
@@ -6,15 +7,62 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||
import { BoxesIcon, SaveIcon, SettingsIcon } from "lucide-react";
|
||||
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const ConfigPage = (): JSX.Element => {
|
||||
const ConfigPage = () => {
|
||||
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
||||
const { hasErrors } = useAppStore();
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||
"device" | "module"
|
||||
>("device");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const isError = hasErrors();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (hasErrors()) {
|
||||
return toast({
|
||||
title: "Config Errors Exist",
|
||||
description: "Please fix the configuration errors before saving.",
|
||||
});
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (activeConfigSection === "device") {
|
||||
await Promise.all(
|
||||
workingConfig.map((config) =>
|
||||
connection?.setConfig(config).then(() =>
|
||||
toast({
|
||||
title: "Saving Config",
|
||||
description: `The configuration change ${config.payloadVariant.case} has been saved.`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Promise.all(
|
||||
workingModuleConfig.map((moduleConfig) =>
|
||||
connection?.setModuleConfig(moduleConfig).then(() =>
|
||||
toast({
|
||||
title: "Saving Config",
|
||||
description: `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await connection?.commitEditSettings();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error Saving Config",
|
||||
description: "An error occurred while saving the configuration.",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,30 +88,11 @@ const ConfigPage = (): JSX.Element => {
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
icon: SaveIcon,
|
||||
async onClick() {
|
||||
if (activeConfigSection === "device") {
|
||||
workingConfig.map(
|
||||
async (config) =>
|
||||
await connection?.setConfig(config).then(() =>
|
||||
toast({
|
||||
title: `Config ${config.payloadVariant.case} saved`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
workingModuleConfig.map(
|
||||
async (moduleConfig) =>
|
||||
await connection?.setModuleConfig(moduleConfig).then(() =>
|
||||
toast({
|
||||
title: `Config ${moduleConfig.payloadVariant.case} saved`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await connection?.commitEditSettings();
|
||||
},
|
||||
icon: isError ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving,
|
||||
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
|
||||
onClick: handleSave,
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user