Merge branch 'master' into add_search_nodes
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist/build.tar
|
||||
dist/output
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
build-and-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -11,8 +11,6 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
+6
-1
@@ -1,4 +1,9 @@
|
||||
FROM registry.access.redhat.com/ubi9/nginx-122:1-45
|
||||
FROM nginx:1.27.2-alpine
|
||||
|
||||
RUN rm -r /usr/share/nginx/html \
|
||||
&& mkdir /usr/share/nginx/html
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
ADD dist .
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
|
||||
## Self-host
|
||||
|
||||
The client can be self hosted using the precompiled container images with an OCI compatible runtime such as [Docker](https://www.docker.com/) or [Podman](https://podman.io/).
|
||||
The base image used is [UBI9 Nginx 1.22](https://catalog.redhat.com/software/containers/ubi9/nginx-122/63f7653b9b0ca19f84f7e9a1)
|
||||
The base image used is [Nginx 1.27](https://hub.docker.com/_/nginx)
|
||||
|
||||
```bash
|
||||
# With Docker
|
||||
docker run -d -p 8080:8080 -p 8443:8443 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
|
||||
#With Podman
|
||||
podman run -d -p 8080:8080 -p 8443:8443 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
```
|
||||
|
||||
## Development & Building
|
||||
|
||||
+59
-55
@@ -5,13 +5,18 @@
|
||||
"description": "Meshtastic web client",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"check": "biome check .",
|
||||
"check:fix": "pnpm check --write",
|
||||
"build": "pnpm check && rsbuild build",
|
||||
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build",
|
||||
"check": "biome check src/",
|
||||
"check:fix": "pnpm check --write src/",
|
||||
"format": "biome format --write src/",
|
||||
"dev": "rsbuild dev --open",
|
||||
"format": "biome format --write",
|
||||
"preview": "rsbuild preview",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)"
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)",
|
||||
"postinstall": "npx simple-git-hooks"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "npm run check:fix && npm run format"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,65 +28,64 @@
|
||||
"homepage": "https://meshtastic.org",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0",
|
||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
||||
"@meshtastic/js": "2.3.7-5",
|
||||
"@noble/curves": "^1.5.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.1",
|
||||
"@turf/turf": "^6.5.0",
|
||||
"@noble/curves": "^1.8.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-menubar": "^1.1.5",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "^1.1.7",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"cmdk": "^1.0.4",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-react": "^0.363.0",
|
||||
"mapbox-gl": "^3.6.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"mapbox-gl": "^3.9.4",
|
||||
"maplibre-gl": "4.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-map-gl": "7.1.7",
|
||||
"react-qrcode-logo": "^2.10.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-map-gl": "7.1.9",
|
||||
"react-qrcode-logo": "^3.0.0",
|
||||
"rfc4648": "^1.5.4",
|
||||
"timeago-react": "^3.0.6",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"zustand": "4.5.2"
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"zustand": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.8.2",
|
||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240906232734-3da561588c55.1",
|
||||
"@rsbuild/core": "^1.0.10",
|
||||
"@rsbuild/plugin-react": "^1.0.3",
|
||||
"@types/chrome": "^0.0.263",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/w3c-web-serial": "^1.0.6",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@rsbuild/core": "^1.2.3",
|
||||
"@rsbuild/plugin-react": "^1.1.0",
|
||||
"@types/chrome": "^0.0.299",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/w3c-web-serial": "^1.0.7",
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"gzipper": "^7.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tar": "^6.2.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.2"
|
||||
}
|
||||
"autoprefixer": "^10.4.20",
|
||||
"gzipper": "^8.2.0",
|
||||
"postcss": "^8.5.1",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar": "^7.4.3",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
}
|
||||
|
||||
Generated
+2338
-2756
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { defineConfig } from "@rsbuild/core";
|
||||
import { pluginReact } from "@rsbuild/plugin-react";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
let hash = "";
|
||||
|
||||
|
||||
+3
-1
@@ -2,8 +2,9 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
import { PageRouter } from "@app/PageRouter.tsx";
|
||||
import { CommandPalette } from "@components/CommandPalette.tsx";
|
||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||
import { KeyBackupReminder } from "@components/KeyBackupReminder";
|
||||
import { Toaster } from "@components/Toaster.tsx";
|
||||
import Footer from "@components/UI/Footer.tsx";
|
||||
import { ThemeController } from "@components/generic/ThemeController.tsx";
|
||||
@@ -37,6 +38,7 @@ export const App = (): JSX.Element => {
|
||||
{device ? (
|
||||
<div className="flex h-screen">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<PageRouter />
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,7 @@ export interface DeviceWrapperProps {
|
||||
device?: Device;
|
||||
}
|
||||
|
||||
export const DeviceWrapper = ({
|
||||
children,
|
||||
device,
|
||||
}: DeviceWrapperProps): JSX.Element => {
|
||||
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
|
||||
return (
|
||||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
|
||||
);
|
||||
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { ChannelsPage } from "@pages/Channels.tsx";
|
||||
import { ConfigPage } from "@pages/Config/index.tsx";
|
||||
import { MapPage } from "@pages/Map.tsx";
|
||||
import { MessagesPage } from "@pages/Messages.tsx";
|
||||
import { NodesPage } from "@pages/Nodes.tsx";
|
||||
import ChannelsPage from "@pages/Channels.tsx";
|
||||
import ConfigPage from "@pages/Config/index.tsx";
|
||||
import MapPage from "@pages/Map.tsx";
|
||||
import MessagesPage from "@pages/Messages.tsx";
|
||||
import NodesPage from "@pages/Nodes.tsx";
|
||||
|
||||
export const PageRouter = (): JSX.Element => {
|
||||
export const PageRouter = () => {
|
||||
const { activePage } = useDevice();
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Avatar } from "@components/UI/Avatar";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
} from "@components/UI/Command.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { useCommandState } from "cmdk";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
@@ -51,11 +51,11 @@ export interface Command {
|
||||
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export const CommandPalette = (): JSX.Element => {
|
||||
export const CommandPalette = () => {
|
||||
const {
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
@@ -125,9 +125,11 @@ export const CommandPalette = (): JSX.Element => {
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Hashicon
|
||||
size={16}
|
||||
value={device.hardware.myNodeNum.toString()}
|
||||
<Avatar
|
||||
text={
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.shortName ?? device.hardware.myNodeNum.toString()
|
||||
}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { Code } from "@components/UI/Typography/Code.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import {
|
||||
HomeIcon,
|
||||
LanguagesIcon,
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
SearchIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { Avatar } from "./UI/Avatar";
|
||||
|
||||
export const DeviceSelector = (): JSX.Element => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
@@ -44,9 +45,12 @@ export const DeviceSelector = (): JSX.Element => {
|
||||
}}
|
||||
active={selectedDevice === device.id}
|
||||
>
|
||||
<Hashicon
|
||||
size={24}
|
||||
value={device.hardware.myNodeNum.toString()}
|
||||
<Avatar
|
||||
text={
|
||||
device.nodes
|
||||
.get(device.hardware.myNodeNum)
|
||||
?.user?.shortName.toString() ?? "UNK"
|
||||
}
|
||||
/>
|
||||
</DeviceSelectorButton>
|
||||
))}
|
||||
|
||||
@@ -8,15 +8,15 @@ export const DeviceSelectorButton = ({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: DeviceSelectorButtonProps): JSX.Element => (
|
||||
}: DeviceSelectorButtonProps) => (
|
||||
<li
|
||||
className="aspect-w-1 aspect-h-1 relative w-full"
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick}
|
||||
>
|
||||
{active && (
|
||||
{/* {active && (
|
||||
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
)} */}
|
||||
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.tsx";
|
||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
@@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => {
|
||||
setDialogOpen("nodeRemoval", open);
|
||||
}}
|
||||
/>
|
||||
<PkiBackupDialog
|
||||
open={dialog.pkiBackup}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("pkiBackup", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
type BrowserFeature,
|
||||
useBrowserFeatureDetection,
|
||||
} from "@app/core/hooks/useBrowserFeatureDetection";
|
||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||
@@ -13,8 +17,10 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { AlertCircle, InfoIcon } from "lucide-react";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
import { Link } from "../UI/Typography/Link";
|
||||
|
||||
export interface TabElementProps {
|
||||
closeDialog: () => void;
|
||||
@@ -23,44 +29,109 @@ export interface TabElementProps {
|
||||
export interface TabManifest {
|
||||
label: string;
|
||||
element: React.FC<TabElementProps>;
|
||||
disabled: boolean;
|
||||
disabledMessage: string;
|
||||
disabledLink?: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
label: "HTTP",
|
||||
element: HTTP,
|
||||
disabled: false,
|
||||
disabledMessage: "Unsuported connection method",
|
||||
},
|
||||
{
|
||||
label: "Bluetooth",
|
||||
element: BLE,
|
||||
disabled: !navigator.bluetooth,
|
||||
disabledMessage:
|
||||
"Web Bluetooth is currently only supported by Chromium-based browsers",
|
||||
disabledLink:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
},
|
||||
{
|
||||
label: "Serial",
|
||||
element: Serial,
|
||||
disabled: !navigator.serial,
|
||||
disabledMessage:
|
||||
"WebSerial is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
},
|
||||
];
|
||||
export interface NewDeviceProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FeatureErrorProps {
|
||||
missingFeatures: BrowserFeature[];
|
||||
}
|
||||
|
||||
const links: { [key: string]: string } = {
|
||||
"Web Bluetooth":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
"Web Serial":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
"Secure Context":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||
};
|
||||
|
||||
const listFormatter = new Intl.ListFormat("en", {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
});
|
||||
|
||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
if (missingFeatures.length === 0) return null;
|
||||
|
||||
const browserFeatures = missingFeatures.filter(
|
||||
(feature) => feature !== "Secure Context",
|
||||
);
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const formatFeatureList = (features: string[]) => {
|
||||
const parts = listFormatter.formatToParts(features);
|
||||
return parts.map((part) => {
|
||||
if (part.type === "element") {
|
||||
return (
|
||||
<Link key={part.value} href={links[part.value]}>
|
||||
{part.value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <Fragment key={part.value}>{part.value}</Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Subtle className="flex flex-col items-start gap-2 text-black bg-red-200/80 p-4 rounded-md">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 flex-shrink-0" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">
|
||||
{browserFeatures.length > 0 && (
|
||||
<>
|
||||
This application requires {formatFeatureList(browserFeatures)}.
|
||||
Please use a Chromium-based browser like Chrome or Edge.
|
||||
</>
|
||||
)}
|
||||
{needsSecureContext && (
|
||||
<>
|
||||
{browserFeatures.length > 0 && " Additionally, it"}
|
||||
{browserFeatures.length === 0 && "This application"} requires a{" "}
|
||||
<Link href={links["Secure Context"]}>secure context</Link>.
|
||||
Please connect using HTTPS or localhost.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Subtle>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewDeviceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NewDeviceProps): JSX.Element => {
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
label: "HTTP",
|
||||
element: HTTP,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
label: "Bluetooth",
|
||||
element: BLE,
|
||||
isDisabled:
|
||||
unsupported.includes("Web Bluetooth") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
{
|
||||
label: "Serial",
|
||||
element: Serial,
|
||||
isDisabled:
|
||||
unsupported.includes("Web Serial") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@@ -70,46 +141,22 @@ export const NewDeviceDialog = ({
|
||||
<Tabs defaultValue="HTTP">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
disabled={tab.disabled}
|
||||
>
|
||||
<TabsTrigger key={tab.label} value={tab.label}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
{tab.disabled ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{tab.disabledMessage}
|
||||
</p>
|
||||
) : (
|
||||
<fieldset disabled={tab.isDisabled}>
|
||||
{tab.isDisabled ? (
|
||||
<ErrorMessage missingFeatures={unsupported} />
|
||||
) : null}
|
||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||
)}
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{(!navigator.bluetooth || !navigator.serial) && (
|
||||
<>
|
||||
<Subtle>
|
||||
Web Bluetooth and Web Serial are currently only supported by
|
||||
Chromium-based browsers.
|
||||
</Subtle>
|
||||
<Subtle>
|
||||
Read more:
|
||||
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility">
|
||||
Web Bluetooth
|
||||
</Link>
|
||||
|
||||
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility">
|
||||
Web Serial
|
||||
</Link>
|
||||
</Subtle>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useDevice } from "@app/core/stores/deviceStore";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export interface PkiBackupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PkiBackupDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PkiBackupDialogProps) => {
|
||||
const { config, setDialogOpen } = useDevice();
|
||||
const privateKey = config.security?.privateKey;
|
||||
const publicKey = config.security?.publicKey;
|
||||
|
||||
const decodeKeyData = React.useCallback(
|
||||
(key: Uint8Array<ArrayBufferLike>) => {
|
||||
if (!key) return "";
|
||||
return fromByteArray(key ?? new Uint8Array(0));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
setDialogOpen("pkiBackup", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const renderPrintWindow = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (printWindow) {
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>=== MESHTASTIC KEYS ===</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
h1 { font-size: 18px; }
|
||||
p { font-size: 14px; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>=== MESHTASTIC KEYS ===</h1>
|
||||
<br>
|
||||
<h2>Public Key:</h2>
|
||||
<p>${decodeKeyData(publicKey)}</p>
|
||||
<h2>Private Key:</h2>
|
||||
<p>${decodeKeyData(privateKey)}</p>
|
||||
<br>
|
||||
<p>=== END OF KEYS ===</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
closeDialog();
|
||||
}
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
||||
|
||||
const createDownloadKeyFile = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
|
||||
const decodedPrivateKey = decodeKeyData(privateKey);
|
||||
const decodedPublicKey = decodeKeyData(publicKey);
|
||||
|
||||
const formattedContent = [
|
||||
"=== MESHTASTIC KEYS ===\n\n",
|
||||
"Private Key:\n",
|
||||
decodedPrivateKey,
|
||||
"\n\nPublic Key:\n",
|
||||
decodedPublicKey,
|
||||
"\n\n=== END OF KEYS ===",
|
||||
].join("");
|
||||
|
||||
const blob = new Blob([formattedContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "meshtastic_keys.txt";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
closeDialog();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup Keys</DialogTitle>
|
||||
<DialogDescription>
|
||||
Its important to backup your public and private keys and store your
|
||||
backup securely!
|
||||
</DialogDescription>
|
||||
<DialogDescription>
|
||||
<span className="font-bold break-before-auto">
|
||||
If you lose your keys, you will need to reset your device.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant={"default"}
|
||||
onClick={() => createDownloadKeyFile()}
|
||||
className=""
|
||||
>
|
||||
<DownloadIcon size={20} className="mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant={"default"} onClick={() => renderPrintWindow()}>
|
||||
<PrinterIcon size={20} className="mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import type { ButtonVariant } from "@components/UI/Button";
|
||||
import { Generator } from "@components/UI/Generator.tsx";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { ChangeEventHandler, MouseEventHandler } from "react";
|
||||
@@ -15,7 +16,12 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||
devicePSKBitCount: number;
|
||||
inputChange: ChangeEventHandler;
|
||||
selectChange: (event: string) => void;
|
||||
buttonClick: MouseEventHandler;
|
||||
actionButtons: {
|
||||
text: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
variant: ButtonVariant;
|
||||
className?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function PasswordGenerator<T extends FieldValues>({
|
||||
@@ -47,10 +53,9 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
bits={field.bits}
|
||||
inputChange={field.inputChange}
|
||||
selectChange={field.selectChange}
|
||||
buttonClick={field.buttonClick}
|
||||
value={value}
|
||||
variant={field.validationText ? "invalid" : "default"}
|
||||
buttonText="Generate"
|
||||
actionButtons={field.actionButtons}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{optionsEnumValues.map(([name, value]) => (
|
||||
<SelectItem key={name} value={value.toString()}>
|
||||
<SelectItem key={name + value} value={value.toString()}>
|
||||
{formatEnumName
|
||||
? name
|
||||
.replace(/_/g, " ")
|
||||
|
||||
@@ -15,9 +15,9 @@ export const FieldWrapper = ({
|
||||
children,
|
||||
valid,
|
||||
validationText,
|
||||
}: FieldWrapperProps): JSX.Element => (
|
||||
}: FieldWrapperProps) => (
|
||||
<div className="pt-6 sm:pt-5">
|
||||
<div role="group" aria-labelledby="label-notifications">
|
||||
<fieldset aria-labelledby="label-notifications">
|
||||
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
|
||||
<Label>{label}</Label>
|
||||
<div className="sm:col-span-2">
|
||||
@@ -32,6 +32,6 @@ export const FieldWrapper = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useBackupReminder } from "@app/core/hooks/useKeyBackupReminder";
|
||||
import { useDevice } from "@app/core/stores/deviceStore";
|
||||
|
||||
export const KeyBackupReminder = (): JSX.Element => {
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
useBackupReminder({
|
||||
reminderInDays: 7,
|
||||
message:
|
||||
"We recommend backing up your key data regularly. Would you like to back up now?",
|
||||
onAccept: () => setDialogOpen("pkiBackup", true),
|
||||
enabled: true,
|
||||
cookieOptions: {
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
return <></>;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { Protobuf } from "@meshtastic/js";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { useState } from "react";
|
||||
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel.Channel;
|
||||
@@ -22,6 +23,8 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
channel?.settings?.psk.length ?? 16,
|
||||
);
|
||||
const [validationText, setValidationText] = useState<string>();
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const onSubmit = (data: ChannelValidation) => {
|
||||
const channel = new Protobuf.Channel.Channel({
|
||||
@@ -46,7 +49,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
});
|
||||
};
|
||||
|
||||
const clickEvent = () => {
|
||||
const preSharedKeyRegenerate = () => {
|
||||
setPass(
|
||||
btoa(
|
||||
cryptoRandomString({
|
||||
@@ -56,6 +59,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
),
|
||||
);
|
||||
setValidationText(undefined);
|
||||
setPreSharedDialogOpen(false);
|
||||
};
|
||||
|
||||
const preSharedClickEvent = () => {
|
||||
setPreSharedDialogOpen(true);
|
||||
};
|
||||
|
||||
const validatePass = (input: string, count: number) => {
|
||||
@@ -79,132 +87,147 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<ChannelValidation>
|
||||
onSubmit={onSubmit}
|
||||
submitType="onSubmit"
|
||||
hasSubmitButton={true}
|
||||
defaultValues={{
|
||||
...channel,
|
||||
...{
|
||||
settings: {
|
||||
...channel?.settings,
|
||||
psk: pass,
|
||||
positionEnabled:
|
||||
channel?.settings?.moduleSettings?.positionPrecision !==
|
||||
undefined &&
|
||||
channel?.settings?.moduleSettings?.positionPrecision > 0,
|
||||
preciseLocation:
|
||||
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision === undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
<>
|
||||
<DynamicForm<ChannelValidation>
|
||||
onSubmit={onSubmit}
|
||||
submitType="onSubmit"
|
||||
hasSubmitButton={true}
|
||||
defaultValues={{
|
||||
...channel,
|
||||
...{
|
||||
settings: {
|
||||
...channel?.settings,
|
||||
psk: pass,
|
||||
positionEnabled:
|
||||
channel?.settings?.moduleSettings?.positionPrecision !==
|
||||
undefined &&
|
||||
channel?.settings?.moduleSettings?.positionPrecision > 0,
|
||||
preciseLocation:
|
||||
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: "Channel Settings",
|
||||
description: "Crypto, MQTT & misc settings",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "role",
|
||||
label: "Role",
|
||||
disabled: channel.index === 0,
|
||||
description:
|
||||
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
|
||||
properties: {
|
||||
enumValue:
|
||||
channel.index === 0
|
||||
? { PRIMARY: 1 }
|
||||
: { DISABLED: 0, SECONDARY: 2 },
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: "Channel Settings",
|
||||
description: "Crypto, MQTT & misc settings",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "role",
|
||||
label: "Role",
|
||||
disabled: channel.index === 0,
|
||||
description:
|
||||
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
|
||||
properties: {
|
||||
enumValue:
|
||||
channel.index === 0
|
||||
? { PRIMARY: 1 }
|
||||
: { DISABLED: 0, SECONDARY: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "settings.psk",
|
||||
label: "pre-Shared Key",
|
||||
description: "256, 128, or 8 bit PSKs allowed",
|
||||
validationText: validationText,
|
||||
devicePSKBitCount: bitCount ?? 0,
|
||||
inputChange: inputChangeEvent,
|
||||
selectChange: selectChangeEvent,
|
||||
buttonClick: clickEvent,
|
||||
hide: true,
|
||||
properties: {
|
||||
value: pass,
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "settings.psk",
|
||||
label: "Pre-Shared Key",
|
||||
description:
|
||||
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
||||
validationText: validationText,
|
||||
devicePSKBitCount: bitCount ?? 0,
|
||||
inputChange: inputChangeEvent,
|
||||
selectChange: selectChangeEvent,
|
||||
actionButtons: [
|
||||
{
|
||||
text: "Generate",
|
||||
variant: "success",
|
||||
onClick: preSharedClickEvent,
|
||||
},
|
||||
],
|
||||
hide: true,
|
||||
properties: {
|
||||
value: pass,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "settings.name",
|
||||
label: "Name",
|
||||
description:
|
||||
"A unique name for the channel <12 bytes, leave blank for default",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.uplinkEnabled",
|
||||
label: "Uplink Enabled",
|
||||
description: "Send messages from the local mesh to MQTT",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.downlinkEnabled",
|
||||
label: "Downlink Enabled",
|
||||
description: "Send messages from MQTT to the local mesh",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.positionEnabled",
|
||||
label: "Allow Position Requests",
|
||||
description: "Send position to channel",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.preciseLocation",
|
||||
label: "Precise Location",
|
||||
description: "Send precise location to channel",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "settings.positionPrecision",
|
||||
label: "Approximate Location",
|
||||
description:
|
||||
"If not sharing precise location, position shared on channel will be accurate within this distance",
|
||||
properties: {
|
||||
enumValue:
|
||||
config.display?.units === 0
|
||||
? {
|
||||
"Within 23 km": 10,
|
||||
"Within 12 km": 11,
|
||||
"Within 5.8 km": 12,
|
||||
"Within 2.9 km": 13,
|
||||
"Within 1.5 km": 14,
|
||||
"Within 700 m": 15,
|
||||
"Within 350 m": 16,
|
||||
"Within 200 m": 17,
|
||||
"Within 90 m": 18,
|
||||
"Within 50 m": 19,
|
||||
}
|
||||
: {
|
||||
"Within 15 miles": 10,
|
||||
"Within 7.3 miles": 11,
|
||||
"Within 3.6 miles": 12,
|
||||
"Within 1.8 miles": 13,
|
||||
"Within 0.9 miles": 14,
|
||||
"Within 0.5 miles": 15,
|
||||
"Within 0.2 miles": 16,
|
||||
"Within 600 feet": 17,
|
||||
"Within 300 feet": 18,
|
||||
"Within 150 feet": 19,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "settings.name",
|
||||
label: "Name",
|
||||
description:
|
||||
"A unique name for the channel <12 bytes, leave blank for default",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.uplinkEnabled",
|
||||
label: "Uplink Enabled",
|
||||
description: "Send messages from the local mesh to MQTT",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.downlinkEnabled",
|
||||
label: "Downlink Enabled",
|
||||
description: "Send messages from MQTT to the local mesh",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.positionEnabled",
|
||||
label: "Allow Position Requests",
|
||||
description: "Send position to channel",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.preciseLocation",
|
||||
label: "Precise Location",
|
||||
description: "Send precise location to channel",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "settings.positionPrecision",
|
||||
label: "Approximate Location",
|
||||
description:
|
||||
"If not sharing precise location, position shared on channel will be accurate within this distance",
|
||||
properties: {
|
||||
enumValue:
|
||||
config.display?.units === 0
|
||||
? {
|
||||
"Within 23 km": 10,
|
||||
"Within 12 km": 11,
|
||||
"Within 5.8 km": 12,
|
||||
"Within 2.9 km": 13,
|
||||
"Within 1.5 km": 14,
|
||||
"Within 700 m": 15,
|
||||
"Within 350 m": 16,
|
||||
"Within 200 m": 17,
|
||||
"Within 90 m": 18,
|
||||
"Within 50 m": 19,
|
||||
}
|
||||
: {
|
||||
"Within 15 miles": 10,
|
||||
"Within 7.3 miles": 11,
|
||||
"Within 3.6 miles": 12,
|
||||
"Within 1.8 miles": 13,
|
||||
"Within 0.9 miles": 14,
|
||||
"Within 0.5 miles": 15,
|
||||
"Within 0.2 miles": 16,
|
||||
"Within 600 feet": 17,
|
||||
"Within 300 feet": 18,
|
||||
"Within 150 feet": 19,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PkiRegenerateDialog
|
||||
open={preSharedDialogOpen}
|
||||
onOpenChange={() => setPreSharedDialogOpen(false)}
|
||||
onSubmit={() => preSharedKeyRegenerate()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,20 +6,16 @@ import { useState } from "react";
|
||||
|
||||
export const Bluetooth = (): JSX.Element => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
const [bluetoothValidationText, setBluetoothValidationText] = useState<string>();
|
||||
const [bluetoothValidationText, setBluetoothValidationText] =
|
||||
useState<string>();
|
||||
|
||||
const bluetoothPinChangeEvent = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (e.target.value[0] == "0")
|
||||
{
|
||||
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value[0] === "0") {
|
||||
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
setBluetoothValidationText("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: BluetoothValidation) => {
|
||||
setWorkingConfig(
|
||||
|
||||
@@ -46,7 +46,7 @@ export const Device = (): JSX.Element => {
|
||||
"Lost and Found":
|
||||
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
|
||||
"TAK Tracker":
|
||||
Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
|
||||
Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
|
||||
},
|
||||
formatEnumName: true,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Security = (): JSX.Element => {
|
||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
||||
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } =
|
||||
useDevice();
|
||||
|
||||
const [privateKey, setPrivateKey] = useState<string>(
|
||||
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
||||
@@ -31,7 +32,8 @@ export const Security = (): JSX.Element => {
|
||||
);
|
||||
const [adminKeyValidationText, setAdminKeyValidationText] =
|
||||
useState<string>();
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const onSubmit = (data: SecurityValidation) => {
|
||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
||||
@@ -71,7 +73,11 @@ export const Security = (): JSX.Element => {
|
||||
};
|
||||
|
||||
const privateKeyClickEvent = () => {
|
||||
setDialogOpen(true);
|
||||
setPrivateKeyDialogOpen(true);
|
||||
};
|
||||
|
||||
const pkiBackupClickEvent = () => {
|
||||
setDialogOpen("pkiBackup", true);
|
||||
};
|
||||
|
||||
const pkiRegenerate = () => {
|
||||
@@ -86,7 +92,7 @@ export const Security = (): JSX.Element => {
|
||||
setPrivateKeyValidationText,
|
||||
);
|
||||
|
||||
setDialogOpen(false);
|
||||
setPrivateKeyDialogOpen(false);
|
||||
};
|
||||
|
||||
const privateKeyInputChangeEvent = (
|
||||
@@ -149,7 +155,18 @@ export const Security = (): JSX.Element => {
|
||||
inputChange: privateKeyInputChangeEvent,
|
||||
selectChange: privateKeySelectChangeEvent,
|
||||
hide: !privateKeyVisible,
|
||||
buttonClick: privateKeyClickEvent,
|
||||
actionButtons: [
|
||||
{
|
||||
text: "Generate",
|
||||
onClick: privateKeyClickEvent,
|
||||
variant: "success",
|
||||
},
|
||||
{
|
||||
text: "Backup Key",
|
||||
onClick: pkiBackupClickEvent,
|
||||
variant: "subtle",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
value: privateKey,
|
||||
action: {
|
||||
@@ -187,7 +204,7 @@ export const Security = (): JSX.Element => {
|
||||
name: "isManaged",
|
||||
label: "Managed",
|
||||
description:
|
||||
'If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.',
|
||||
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
@@ -228,8 +245,8 @@ export const Security = (): JSX.Element => {
|
||||
]}
|
||||
/>
|
||||
<PkiRegenerateDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={() => setDialogOpen(false)}
|
||||
open={privateKeyDialogOpen}
|
||||
onOpenChange={() => setPrivateKeyDialogOpen(false)}
|
||||
onSubmit={() => pkiRegenerate()}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -14,13 +14,13 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const updateSerialPortList = useCallback(async () => {
|
||||
setSerialPorts(await navigator.serial.getPorts());
|
||||
setSerialPorts(await navigator?.serial.getPorts());
|
||||
}, []);
|
||||
|
||||
navigator.serial.addEventListener("connect", () => {
|
||||
navigator?.serial?.addEventListener("connect", () => {
|
||||
updateSerialPortList();
|
||||
});
|
||||
navigator.serial.addEventListener("disconnect", () => {
|
||||
navigator?.serial?.addEventListener("disconnect", () => {
|
||||
updateSerialPortList();
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Separator } from "@app/components/UI/Seperator";
|
||||
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import {
|
||||
BatteryChargingIcon,
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
Dot,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MountainSnow,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
node: ProtobufType.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
||||
const hardwareType = Protobuf.Mesh.HardwareModel[
|
||||
node.user?.hwModel ?? 0
|
||||
].replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<div className="dark:text-black">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={node.user?.shortName} />
|
||||
|
||||
<div>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon
|
||||
className="text-green-600"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label="Public Key Enabled"
|
||||
/>
|
||||
) : (
|
||||
<LockOpenIcon
|
||||
className="text-yellow-500"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label="No Public Key"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Star
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<H5>{name}</H5>
|
||||
|
||||
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
|
||||
|
||||
{!!node.deviceMetrics?.batteryLevel && (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title={`${
|
||||
node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||
} volts`}
|
||||
>
|
||||
{node.deviceMetrics?.batteryLevel > 100 ? (
|
||||
<BatteryChargingIcon size={22} />
|
||||
) : node.deviceMetrics?.batteryLevel > 80 ? (
|
||||
<BatteryFullIcon size={22} />
|
||||
) : node.deviceMetrics?.batteryLevel > 20 ? (
|
||||
<BatteryMediumIcon size={22} />
|
||||
) : (
|
||||
<BatteryLowIcon size={22} />
|
||||
)}
|
||||
<Subtle aria-label="Battery">
|
||||
{node.deviceMetrics?.batteryLevel > 100
|
||||
? "Charging"
|
||||
: `${node.deviceMetrics?.batteryLevel}%`}
|
||||
</Subtle>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{node.user?.shortName && <div>"{node.user?.shortName}"</div>}
|
||||
{node.user?.id && <div>{node.user?.id}</div>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-1"
|
||||
title={new Date(node.lastHeard * 1000).toLocaleString(
|
||||
navigator.language,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{node.lastHeard > 0 && (
|
||||
<div>
|
||||
Heard <TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{node.viaMqtt && (
|
||||
<div style={{ color: "#660066" }} className="font-medium">
|
||||
MQTT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-1" />
|
||||
|
||||
<div className="flex mt-2 text-sm">
|
||||
<div className="flex items-center flex-grow">
|
||||
<div className="border-2 border-black rounded px-0.5 mr-1">
|
||||
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway}
|
||||
</div>
|
||||
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>
|
||||
</div>
|
||||
{node.position?.altitude && (
|
||||
<div className="flex items-center flex-grow">
|
||||
<MountainSnow
|
||||
size={15}
|
||||
className="ml-2 mr-1"
|
||||
aria-label="Elevation"
|
||||
/>
|
||||
<div>{node.position?.altitude} ft</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex mt-2">
|
||||
{!!node.deviceMetrics?.channelUtilization && (
|
||||
<div className="flex-grow">
|
||||
<div>Channel Util</div>
|
||||
<Mono>
|
||||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="flex-grow">
|
||||
<div>Airtime Util</div>
|
||||
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.snr !== 0 && (
|
||||
<div className="mt-2">
|
||||
<div>SNR</div>
|
||||
<Mono className="flex items-center text-xs">
|
||||
{node.snr}db
|
||||
<Dot />
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
|
||||
<Dot />
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MessageWithState } from "@app/core/stores/deviceStore.ts";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Avatar } from "@components/UI/Avatar";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -13,11 +13,7 @@ export interface MessageProps {
|
||||
sender?: Protobuf.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const Message = ({
|
||||
lastMsgSameUser,
|
||||
message,
|
||||
sender,
|
||||
}: MessageProps): JSX.Element => {
|
||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
return lastMsgSameUser ? (
|
||||
<div className="ml-5 flex">
|
||||
{message.state === "ack" ? (
|
||||
@@ -39,11 +35,14 @@ export const Message = ({
|
||||
<div className="mx-4 mt-2 gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 cursor-pointer">
|
||||
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
|
||||
<Avatar text={sender?.user?.shortName ?? "UNK"} />
|
||||
</div>
|
||||
<span className="cursor-pointer font-medium text-textPrimary">
|
||||
{sender?.user?.longName ?? "UNK"}
|
||||
</span>
|
||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||
{message.rxTime.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||
{message.rxTime.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { debounce } from "@app/core/utils/debounce";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export interface MessageInputProps {
|
||||
to: Types.Destination;
|
||||
@@ -20,32 +22,46 @@ export const MessageInput = ({
|
||||
setMessageDraft,
|
||||
hardware,
|
||||
} = useDevice();
|
||||
|
||||
const myNodeNum = hardware.myNodeNum;
|
||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||
|
||||
const sendText = async (message: string) => {
|
||||
await connection
|
||||
?.sendText(message, to, true, channel)
|
||||
.then((id) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
id,
|
||||
"ack",
|
||||
),
|
||||
)
|
||||
.catch((e: Types.PacketError) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
e.id,
|
||||
e.error,
|
||||
),
|
||||
);
|
||||
const debouncedSetMessageDraft = useMemo(
|
||||
() => debounce(setMessageDraft, 300),
|
||||
[setMessageDraft],
|
||||
);
|
||||
|
||||
const sendText = useCallback(
|
||||
async (message: string) => {
|
||||
await connection
|
||||
?.sendText(message, to, true, channel)
|
||||
.then((id) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
id,
|
||||
"ack",
|
||||
),
|
||||
)
|
||||
.catch((e: Types.PacketError) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
e.id,
|
||||
e.error,
|
||||
),
|
||||
);
|
||||
},
|
||||
[channel, connection, myNodeNum, setMessageState, to],
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalDraft(newValue);
|
||||
debouncedSetMessageDraft(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -54,7 +70,8 @@ export const MessageInput = ({
|
||||
className="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendText(messageDraft);
|
||||
sendText(localDraft);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
}}
|
||||
>
|
||||
@@ -64,8 +81,8 @@ export const MessageInput = ({
|
||||
autoFocus={true}
|
||||
minLength={1}
|
||||
placeholder="Enter Message"
|
||||
value={messageDraft}
|
||||
onChange={(e) => setMessageDraft(e.target.value)}
|
||||
value={localDraft}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</span>
|
||||
<Button type="submit">
|
||||
|
||||
@@ -35,7 +35,7 @@ export const PageLayout = ({
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={action.icon.name}
|
||||
key={action.icon.displayName}
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
SettingsIcon,
|
||||
SidebarCloseIcon,
|
||||
SidebarOpenIcon,
|
||||
UsersIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface SidebarProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -25,6 +28,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
const myNode = nodes.get(hardware.myNodeNum);
|
||||
const myMetadata = metadata.get(0);
|
||||
const { activePage, setActivePage, setDialogOpen } = useDevice();
|
||||
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
||||
|
||||
interface NavLink {
|
||||
name: string;
|
||||
@@ -60,7 +64,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
return showSidebar ? (
|
||||
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] border-slate-300 bg-transparent dark:border-slate-700">
|
||||
<div className="flex justify-between px-8 pt-6">
|
||||
<div>
|
||||
@@ -76,11 +80,20 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
>
|
||||
<EditIcon size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowSidebar(false)}>
|
||||
<SidebarCloseIcon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-8 pb-6">
|
||||
<div className="flex items-center">
|
||||
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
|
||||
<Subtle>{myNode?.deviceMetrics?.batteryLevel ? myNode?.deviceMetrics?.batteryLevel > 100 ? "Charging" : myNode?.deviceMetrics?.batteryLevel + "%" : "UNK"}</Subtle>
|
||||
<Subtle>
|
||||
{myNode?.deviceMetrics?.batteryLevel
|
||||
? myNode?.deviceMetrics?.batteryLevel > 100
|
||||
? "Charging"
|
||||
: `${myNode?.deviceMetrics?.batteryLevel}%`
|
||||
: "UNK"}
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ZapIcon size={24} viewBox={"0 0 36 24"} />
|
||||
@@ -109,5 +122,11 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
</SidebarSection>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1 pt-8 border-r-[0.5px] border-slate-700">
|
||||
<button type="button" onClick={() => setShowSidebar(true)}>
|
||||
<SidebarOpenIcon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+11
-13
@@ -1,5 +1,3 @@
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
@@ -7,24 +5,24 @@ import {
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@components/UI/Toast.tsx";
|
||||
} from "@components/UI/Toast";
|
||||
import { useToast } from "@core/hooks/useToast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
{toasts.map(({ id, title, description, action, duration, ...props }) => (
|
||||
<Toast
|
||||
key={id}
|
||||
{...props}
|
||||
duration={duration}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
{title && (
|
||||
<ToastTitle className="dark:text-white">{title}</ToastTitle>
|
||||
)}
|
||||
{description && (
|
||||
<ToastDescription className="dark:text-white-400">
|
||||
{description}
|
||||
</ToastDescription>
|
||||
)}
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
import type React from "react";
|
||||
|
||||
type RGBColor = {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
};
|
||||
|
||||
interface AvatarProps {
|
||||
text: string;
|
||||
size?: "sm" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
|
||||
class ColorUtils {
|
||||
static hexToRgb(hex: number): RGBColor {
|
||||
return {
|
||||
r: (hex & 0xff0000) >> 16,
|
||||
g: (hex & 0x00ff00) >> 8,
|
||||
b: hex & 0x0000ff,
|
||||
a: 255,
|
||||
};
|
||||
}
|
||||
|
||||
static rgbToHex(color: RGBColor): number {
|
||||
return (
|
||||
(Math.round(color.a) << 24) |
|
||||
(Math.round(color.r) << 16) |
|
||||
(Math.round(color.g) << 8) |
|
||||
Math.round(color.b)
|
||||
);
|
||||
}
|
||||
|
||||
static isLight(color: RGBColor): boolean {
|
||||
const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
|
||||
return brightness > 127.5;
|
||||
}
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<AvatarProps> = ({
|
||||
text,
|
||||
size = "sm",
|
||||
className,
|
||||
}) => {
|
||||
const sizes = {
|
||||
sm: "size-11 text-xs",
|
||||
lg: "size-16 text-lg",
|
||||
};
|
||||
|
||||
// Pick a color based on the text provided to function
|
||||
const getColorFromText = (text: string): RGBColor => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = text.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return {
|
||||
r: (hash & 0xff0000) >> 16,
|
||||
g: (hash & 0x00ff00) >> 8,
|
||||
b: hash & 0x0000ff,
|
||||
a: 255,
|
||||
};
|
||||
};
|
||||
|
||||
const bgColor = getColorFromText(text ?? "UNK");
|
||||
const isLight = ColorUtils.isLight(bgColor);
|
||||
const textColor = isLight ? "#000000" : "#FFFFFF";
|
||||
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`
|
||||
rounded-full
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
size-11
|
||||
font-semibold`,
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
<p className="p-1">{initials}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,8 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import {
|
||||
Select,
|
||||
@@ -16,11 +16,15 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
devicePSKBitCount?: number;
|
||||
value: string;
|
||||
variant: "default" | "invalid";
|
||||
buttonText?: string;
|
||||
actionButtons: {
|
||||
text: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
variant: ButtonVariant;
|
||||
className?: string;
|
||||
}[];
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
selectChange: (event: string) => void;
|
||||
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
action?: {
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
@@ -35,15 +39,15 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
devicePSKBitCount,
|
||||
variant,
|
||||
value,
|
||||
buttonText,
|
||||
actionButtons,
|
||||
bits = [
|
||||
{ text: "256 bit", value: "32", key: "bit256" },
|
||||
{ text: "128 bit", value: "16", key: "bit128" },
|
||||
{ text: "8 bit", value: "1", key: "bit8" },
|
||||
{ text: "Empty", value: "0", key: "empty" },
|
||||
],
|
||||
selectChange,
|
||||
inputChange,
|
||||
buttonClick,
|
||||
action,
|
||||
disabled,
|
||||
...props
|
||||
@@ -93,15 +97,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="success"
|
||||
onClick={buttonClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<div className="flex ml-4 space-x-4">
|
||||
{actionButtons?.map(({ text, onClick, variant, className }) => (
|
||||
<Button
|
||||
key={text}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,9 +9,9 @@ export interface SidebarSectionProps {
|
||||
export const SidebarSection = ({
|
||||
label: title,
|
||||
children,
|
||||
}: SidebarSectionProps): JSX.Element => (
|
||||
}: SidebarSectionProps) => (
|
||||
<div className="px-4 py-2">
|
||||
<H4 className="mb-2 ml-2">{title}</H4>
|
||||
<H4 className="mb-3 ml-2">{title}</H4>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { cn } from "@core/utils/cn";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-50 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-24 sm:right-6 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,14 +23,14 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"data-[swipe=move]:transition-none grow-1 group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full mt-4 data-[state=closed]:slide-out-to-right-full dark:border-slate-700 last:mt-0 sm:last:mt-4",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700",
|
||||
"border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
|
||||
destructive:
|
||||
"group destructive bg-red-600 text-white border-red-600 dark:border-red-600",
|
||||
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -61,7 +61,7 @@ const ToastAction = React.forwardRef<
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-red-100 group-[.destructive]:hover:border-slate-50 group-[.destructive]:hover:bg-red-100 group-[.destructive]:hover:text-red-600 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800",
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -76,7 +76,7 @@ const ToastClose = React.forwardRef<
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-slate-500 opacity-0 transition-opacity hover:text-slate-900 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:hover:text-slate-50",
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-slate-400 dark:hover:text-slate-50",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
|
||||
export interface H5Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H5 = ({ className, children }: H5Props): JSX.Element => (
|
||||
<h5
|
||||
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
@@ -1,14 +1,20 @@
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
|
||||
export interface LinkProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Link = ({ href, children }: LinkProps): JSX.Element => (
|
||||
export const Link = ({ href, children, className }: LinkProps): JSX.Element => (
|
||||
<a
|
||||
href={href}
|
||||
target={"_blank"}
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50"
|
||||
className={cn(
|
||||
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -74,15 +74,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
{heading.title}
|
||||
{sortColumn === heading.title && (
|
||||
<>
|
||||
{sortOrder === "asc" ? (
|
||||
<ChevronUpIcon size={16} />
|
||||
) : (
|
||||
<ChevronDownIcon size={16} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sortColumn === heading.title &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUpIcon size={16} />
|
||||
) : (
|
||||
<ChevronDownIcon size={16} />
|
||||
))}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
@@ -94,8 +91,7 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
|
||||
<tr key={index}>
|
||||
{row.map((item, index) => (
|
||||
<td
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
|
||||
key={index}
|
||||
key={item.key ?? index}
|
||||
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
|
||||
>
|
||||
{item}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context";
|
||||
|
||||
interface BrowserSupport {
|
||||
supported: BrowserFeature[];
|
||||
unsupported: BrowserFeature[];
|
||||
}
|
||||
|
||||
export function useBrowserFeatureDetection(): BrowserSupport {
|
||||
const support = useMemo(() => {
|
||||
const features: [BrowserFeature, boolean][] = [
|
||||
["Web Bluetooth", !!navigator?.bluetooth],
|
||||
["Web Serial", !!navigator?.serial],
|
||||
[
|
||||
"Secure Context",
|
||||
window.location.protocol === "https:" ||
|
||||
window.location.hostname === "localhost",
|
||||
],
|
||||
];
|
||||
|
||||
return features.reduce<BrowserSupport>(
|
||||
(acc, [feature, isSupported]) => {
|
||||
const list = isSupported ? acc.supported : acc.unsupported;
|
||||
list.push(feature);
|
||||
return acc;
|
||||
},
|
||||
{ supported: [], unsupported: [] },
|
||||
);
|
||||
}, []);
|
||||
|
||||
return support;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Cookies, { type CookieAttributes } from "js-cookie";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface CookieHookResult<T> {
|
||||
value: T | undefined;
|
||||
setCookie: (value: T, options?: CookieAttributes) => void;
|
||||
removeCookie: () => void;
|
||||
}
|
||||
|
||||
function useCookie<T extends object>(
|
||||
cookieName: string,
|
||||
initialValue?: T,
|
||||
): CookieHookResult<T> {
|
||||
const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
|
||||
try {
|
||||
const cookie = Cookies.get(cookieName);
|
||||
return cookie ? (JSON.parse(cookie) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing cookie ${cookieName}:`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setCookie = useCallback(
|
||||
(value: T, options?: CookieAttributes) => {
|
||||
try {
|
||||
Cookies.set(cookieName, JSON.stringify(value), options);
|
||||
setCookieValue(value);
|
||||
} catch (error) {
|
||||
console.error(`Error setting cookie ${cookieName}:`, error);
|
||||
}
|
||||
},
|
||||
[cookieName],
|
||||
);
|
||||
|
||||
const removeCookie = useCallback(() => {
|
||||
try {
|
||||
Cookies.remove(cookieName);
|
||||
setCookieValue(undefined);
|
||||
} catch (error) {
|
||||
console.error(`Error removing cookie ${cookieName}:`, error);
|
||||
}
|
||||
}, [cookieName]);
|
||||
|
||||
return {
|
||||
value: cookieValue,
|
||||
setCookie,
|
||||
removeCookie,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCookie;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Button } from "@app/components/UI/Button";
|
||||
import type { CookieAttributes } from "js-cookie";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import useCookie from "./useCookie";
|
||||
import { useToast } from "./useToast";
|
||||
|
||||
interface UseBackupReminderOptions {
|
||||
reminderInDays?: number;
|
||||
message: string;
|
||||
onAccept?: () => void | Promise<void>;
|
||||
enabled: boolean;
|
||||
cookieOptions?: CookieAttributes;
|
||||
}
|
||||
|
||||
interface ReminderState {
|
||||
suppressed: boolean;
|
||||
lastShown: string;
|
||||
}
|
||||
|
||||
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
|
||||
const TOAST_DURATION = 30_000; // 30 seconds;:
|
||||
|
||||
// remind user in 1 year to backup keys again, if they accept the reminder;
|
||||
const ON_ACCEPT_REMINDER_DAYS = 365;
|
||||
|
||||
function isReminderExpired(lastShown: string): boolean {
|
||||
const lastShownDate = new Date(lastShown);
|
||||
const now = new Date();
|
||||
const daysSinceLastShown =
|
||||
(now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
return daysSinceLastShown >= 7;
|
||||
}
|
||||
|
||||
export function useBackupReminder({
|
||||
reminderInDays = 7,
|
||||
enabled,
|
||||
message,
|
||||
onAccept = () => {},
|
||||
cookieOptions,
|
||||
}: UseBackupReminderOptions) {
|
||||
const { toast } = useToast();
|
||||
const toastShownRef = useRef(false);
|
||||
const { value: reminderCookie, setCookie } = useCookie<ReminderState>(
|
||||
"key_backup_reminder",
|
||||
);
|
||||
|
||||
const suppressReminder = useCallback(
|
||||
(days: number) => {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + days);
|
||||
|
||||
setCookie(
|
||||
{
|
||||
suppressed: true,
|
||||
lastShown: new Date().toISOString(),
|
||||
},
|
||||
{ ...cookieOptions, expires: expiryDate },
|
||||
);
|
||||
},
|
||||
[setCookie, cookieOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || toastShownRef.current) return;
|
||||
|
||||
const shouldShowReminder =
|
||||
!reminderCookie?.suppressed ||
|
||||
isReminderExpired(reminderCookie.lastShown);
|
||||
if (!shouldShowReminder) return;
|
||||
|
||||
toastShownRef.current = true;
|
||||
|
||||
const { dismiss } = toast({
|
||||
title: "Backup Reminder",
|
||||
duration: TOAST_DURATION,
|
||||
delay: TOAST_APPEAR_DELAY,
|
||||
description: message,
|
||||
action: (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
onAccept();
|
||||
dismiss();
|
||||
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
|
||||
}}
|
||||
>
|
||||
Back up now
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
suppressReminder(reminderInDays);
|
||||
}}
|
||||
>
|
||||
Remind me in {reminderInDays} days
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (!toastShownRef.current) {
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
message,
|
||||
onAccept,
|
||||
reminderInDays,
|
||||
suppressReminder,
|
||||
toast,
|
||||
reminderCookie,
|
||||
]);
|
||||
}
|
||||
+31
-28
@@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ToastActionElement;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
@@ -30,21 +31,21 @@ type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
@@ -80,7 +81,7 @@ export const reducer = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -102,10 +103,10 @@ export const reducer = (state: State, action: Action): State => {
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -137,7 +138,7 @@ function dispatch(action: Action) {
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
function toast({ delay = 0, ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
@@ -147,17 +148,19 @@ function toast({ ...props }: Toast) {
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}, delay);
|
||||
|
||||
return {
|
||||
id: id,
|
||||
|
||||
@@ -25,7 +25,8 @@ export type DialogVariant =
|
||||
| "shutdown"
|
||||
| "reboot"
|
||||
| "deviceName"
|
||||
| "nodeRemoval";
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup";
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
@@ -60,6 +61,7 @@ export interface Device {
|
||||
reboot: boolean;
|
||||
deviceName: boolean;
|
||||
nodeRemoval: boolean;
|
||||
pkiBackup: boolean;
|
||||
};
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
@@ -142,6 +144,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
reboot: false,
|
||||
deviceName: false,
|
||||
nodeRemoval: false,
|
||||
pkiBackup: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
type Callback<Args extends unknown[]> = (...args: Args) => void;
|
||||
|
||||
export function debounce<Args extends unknown[]>(
|
||||
callback: Callback<Args>,
|
||||
wait: number,
|
||||
): Callback<Args> {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -97,6 +97,5 @@
|
||||
}
|
||||
|
||||
img {
|
||||
-drag: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) =>
|
||||
? "Primary"
|
||||
: `Ch ${channel.index}`;
|
||||
|
||||
export const ChannelsPage = (): JSX.Element => {
|
||||
const ChannelsPage = () => {
|
||||
const { channels, setDialogOpen } = useDevice();
|
||||
const [activeChannel, setActiveChannel] = useState<Types.ChannelNumber>(
|
||||
Types.ChannelNumber.Primary,
|
||||
@@ -69,3 +69,4 @@ export const ChannelsPage = (): JSX.Element => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ChannelsPage;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||
import { BoxesIcon, SaveIcon, SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const ConfigPage = (): JSX.Element => {
|
||||
const ConfigPage = (): JSX.Element => {
|
||||
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||
"device" | "module"
|
||||
@@ -72,3 +72,5 @@ export const ConfigPage = (): JSX.Element => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { setConnectDialogOpen } = useAppStore();
|
||||
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { darkMode } = useAppStore();
|
||||
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
@@ -38,7 +39,13 @@ export const Dashboard = () => {
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full px-4 py-4 sm:px-6 ${darkMode ? "hover:bg-slate-800 focus:bg-slate-400 active:bg-slate-600" : "hover:bg-slate-50 focus:bg-slate-50 active:bg-slate-100"}`}
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
@@ -75,7 +82,7 @@ export const Dashboard = () => {
|
||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
+40
-12
@@ -1,3 +1,5 @@
|
||||
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
|
||||
import { Avatar } from "@app/components/UI/Avatar";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
@@ -6,7 +8,7 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
import {
|
||||
@@ -15,18 +17,20 @@ import {
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Marker, useMap } from "react-map-gl";
|
||||
import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AttributionControl, Marker, Popup, useMap } from "react-map-gl";
|
||||
import MapGl from "react-map-gl/maplibre";
|
||||
|
||||
export const MapPage = (): JSX.Element => {
|
||||
const MapPage = (): JSX.Element => {
|
||||
const { nodes, waypoints } = useDevice();
|
||||
const { rasterSources, darkMode } = useAppStore();
|
||||
const { default: map } = useMap();
|
||||
|
||||
const [zoom, setZoom] = useState(0);
|
||||
const [selectedNode, setSelectedNode] =
|
||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||
|
||||
const allNodes = Array.from(nodes.values());
|
||||
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
||||
|
||||
const getBBox = useCallback(() => {
|
||||
if (!map) {
|
||||
@@ -126,13 +130,12 @@ export const MapPage = (): JSX.Element => {
|
||||
// }}
|
||||
|
||||
// @ts-ignore
|
||||
|
||||
attributionControl={false}
|
||||
renderWorldCopies={false}
|
||||
maxPitch={0}
|
||||
style={{
|
||||
filter: darkMode
|
||||
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
|
||||
: "",
|
||||
filter: darkMode ? "brightness(0.8)" : "",
|
||||
}}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={false}
|
||||
@@ -142,6 +145,12 @@ export const MapPage = (): JSX.Element => {
|
||||
longitude: 0,
|
||||
}}
|
||||
>
|
||||
<AttributionControl
|
||||
style={{
|
||||
background: darkMode ? "#ffffff" : "",
|
||||
color: darkMode ? "black" : "",
|
||||
}}
|
||||
/>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
@@ -160,15 +169,16 @@ export const MapPage = (): JSX.Element => {
|
||||
</Source>
|
||||
))} */}
|
||||
{allNodes.map((node) => {
|
||||
if (node.position?.latitudeI) {
|
||||
if (node.position?.latitudeI && node.num !== selectedNode?.num) {
|
||||
return (
|
||||
<Marker
|
||||
key={node.num}
|
||||
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
||||
style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
// style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
anchor="bottom"
|
||||
onClick={() => {
|
||||
setSelectedNode(node);
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
@@ -178,8 +188,13 @@ export const MapPage = (): JSX.Element => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
||||
<Hashicon value={node.num.toString()} size={22} />
|
||||
<div className="flex cursor-pointer gap-2 rounded-md bg-transparent p-1.5">
|
||||
<Avatar
|
||||
text={
|
||||
node.user?.shortName.toString() ?? node.num.toString()
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||
{node.user?.longName ||
|
||||
`!${numberToHexUnpadded(node.num)}`}
|
||||
@@ -189,8 +204,21 @@ export const MapPage = (): JSX.Element => {
|
||||
);
|
||||
}
|
||||
})}
|
||||
{selectedNode?.position && (
|
||||
<Popup
|
||||
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7}
|
||||
anchor="left"
|
||||
closeOnClick={false}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
)}
|
||||
</MapGl>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapPage;
|
||||
|
||||
+27
-16
@@ -1,18 +1,18 @@
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Device, useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { getChannelName } from "@pages/Channels.tsx";
|
||||
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const MessagesPage = (): JSX.Element => {
|
||||
const MessagesPage = () => {
|
||||
const { channels, nodes, hardware, messages, traceroutes, connection } =
|
||||
useDevice();
|
||||
const [chatType, setChatType] =
|
||||
@@ -68,18 +68,27 @@ export const MessagesPage = (): JSX.Element => {
|
||||
className="w-full p-2 border border-gray-300 rounded bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
{filteredNodes.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`}
|
||||
active={activeChat === node.num}
|
||||
onClick={() => {
|
||||
setChatType("direct");
|
||||
setActiveChat(node.num);
|
||||
}}
|
||||
element={<Hashicon size={20} value={node.num.toString()} />}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredNodes.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
label={
|
||||
node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`
|
||||
}
|
||||
active={activeChat === node.num}
|
||||
onClick={() => {
|
||||
setChatType("direct");
|
||||
setActiveChat(node.num);
|
||||
}}
|
||||
element={
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? node.num.toString()}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
<div className="flex flex-col flex-grow">
|
||||
@@ -88,7 +97,7 @@ export const MessagesPage = (): JSX.Element => {
|
||||
chatType === "broadcast" && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
? nodes.get(activeChat)?.user?.longName ?? nodeHex
|
||||
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
||||
: "Loading..."
|
||||
}`}
|
||||
actions={
|
||||
@@ -158,3 +167,5 @@ export const MessagesPage = (): JSX.Element => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
|
||||
+9
-4
@@ -6,12 +6,11 @@ import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, type JSX } from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
export interface DeleteNoteDialogProps {
|
||||
@@ -19,7 +18,7 @@ export interface DeleteNoteDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodesPage = (): JSX.Element => {
|
||||
const NodesPage = (): JSX.Element => {
|
||||
const { nodes, hardware, setDialogOpen } = useDevice();
|
||||
const { setNodeNumToBeRemoved } = useAppStore();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
@@ -57,7 +56,11 @@ export const NodesPage = (): JSX.Element => {
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
||||
<span
|
||||
key={node.num}
|
||||
className="h-3 w-3 rounded-full bg-accent"
|
||||
/>,
|
||||
|
||||
<h1 key="header">
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
@@ -124,3 +127,5 @@ export const NodesPage = (): JSX.Element => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodesPage;
|
||||
|
||||
+1
-1
@@ -35,6 +35,6 @@
|
||||
],
|
||||
"strictPropertyInitialization": false,
|
||||
"experimentalDecorators": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user