mirror of
https://github.com/meshtastic/web.git
synced 2026-05-05 21:25:01 -04:00
UI Overhaul (#3)
* WIP * WIP * WIP * WIP * Draw improvements, disabled user zooming * Update status indicator * Add dynamic channel name with icon * Add more settings * WIP * WIP
This commit is contained in:
committed by
GitHub
parent
f898b1dd49
commit
e14c628ecf
21
.eslintrc
21
.eslintrc
@@ -1,22 +1,3 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-imports": "error"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
"extends": ["@verypossible/eslint-config/react"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Meshtastic.js
|
||||
# Meshtastic Web
|
||||
|
||||
[](https://open.vscode.dev/meshtastic/meshtastic-web)
|
||||
|
||||
|
||||
23
package.json
23
package.json
@@ -11,12 +11,11 @@
|
||||
"lint": "eslint 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@headlessui/react": "^1.4.0",
|
||||
"@heroicons/react": "^1.0.1",
|
||||
"@meshtastic/meshtasticjs": "^0.6.16",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"boring-avatars": "^1.5.8",
|
||||
"framer-motion": "^4.1.17",
|
||||
"i18next": "^20.3.5",
|
||||
"i18next-browser-languagedetector": "^6.1.2",
|
||||
"react": "^17.0.2",
|
||||
@@ -24,30 +23,34 @@
|
||||
"react-flags-select": "^2.1.2",
|
||||
"react-hook-form": "^7.9.0",
|
||||
"react-i18next": "^11.11.4",
|
||||
"react-redux": "^7.2.4"
|
||||
"react-redux": "^7.2.4",
|
||||
"type-route": "^0.6.0",
|
||||
"use-breakpoint": "^2.0.1",
|
||||
"yarn": "^1.22.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@snowpack/plugin-dotenv": "^2.0.5",
|
||||
"@snowpack/plugin-postcss": "^1.4.3",
|
||||
"@snowpack/plugin-react-refresh": "^2.5.0",
|
||||
"@snowpack/plugin-typescript": "^1.2.0",
|
||||
"@types/eslint": "^7.2.13",
|
||||
"@types/react": "^17.0.13",
|
||||
"@types/react-dom": "^17.0.8",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/snowpack-env": "^2.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.1",
|
||||
"@typescript-eslint/parser": "^4.28.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.1",
|
||||
"@typescript-eslint/parser": "^4.29.1",
|
||||
"@verypossible/eslint-config": "^1.6.0",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"eslint": "^7.29.0",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-import-resolver-babel-module": "^5.3.1",
|
||||
"eslint-import-resolver-typescript": "^2.4.0",
|
||||
"eslint-plugin-import": "^2.24.0",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"gzipper": "^5.0.0",
|
||||
"postcss": "^8.3.5",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"prettier": "^2.3.2",
|
||||
"snowpack": "^3.7.1",
|
||||
"tailwindcss": "^2.2.4",
|
||||
|
||||
@@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
12
public/Mesh_Logo_Black.svg
Normal file
12
public/Mesh_Logo_Black.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
|
||||
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
|
||||
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
|
||||
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
12
public/Mesh_Logo_White.svg
Normal file
12
public/Mesh_Logo_White.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
|
||||
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
|
||||
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z" style="fill:white;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
|
||||
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z" style="fill:white;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -8,13 +8,27 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#67ea94" />
|
||||
<link href="https://rsms.me/inter/inter.css" rel="stylesheet" />
|
||||
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<meta name="theme-color" content="#67ea94" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-snowpack-app"
|
||||
/>
|
||||
<title>Snowpack App</title>
|
||||
<title>Meshtastic Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -16,6 +16,16 @@ export default {
|
||||
},
|
||||
],
|
||||
],
|
||||
alias: {
|
||||
// Type 1: Package Import Alias
|
||||
// "lodash": "lodash-es",
|
||||
// Type 2: Local Directory Import Alias (relative to cwd)
|
||||
'@app': './src',
|
||||
'@pages': './src/pages',
|
||||
'@components': './src/components',
|
||||
'@core': './src/core',
|
||||
'@static': './src/static',
|
||||
},
|
||||
routes: [
|
||||
/* Enable an SPA Fallback in development: */
|
||||
// {"match": "routes", "src": ".*", "dest": "/index.html"},
|
||||
|
||||
117
src/App.tsx
117
src/App.tsx
@@ -1,37 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Protobuf, SettingsManager, Types } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { Header } from './components/Header';
|
||||
import { connection } from './connection';
|
||||
import { useAppDispatch } from './hooks/redux';
|
||||
import { Main } from './Main';
|
||||
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
|
||||
import { DeviceStatusDropdown } from '@components/menu/buttons/DeviceStatusDropdown';
|
||||
import { MobileNavToggle } from '@components/menu/buttons/MobileNavToggle';
|
||||
import { ThemeToggle } from '@components/menu/buttons/ThemeToggle';
|
||||
import { Logo } from '@components/menu/Logo';
|
||||
import { MobileNav } from '@components/menu/MobileNav';
|
||||
import { Navigation } from '@components/menu/Navigation';
|
||||
import { connection } from '@core/connection';
|
||||
import { useRoute } from '@core/router';
|
||||
import {
|
||||
ackMessage,
|
||||
addChannel,
|
||||
addMessage,
|
||||
addNode,
|
||||
setDeviceStatus,
|
||||
setLastMeshInterraction,
|
||||
setMyNodeInfo,
|
||||
setPreferences,
|
||||
setReady,
|
||||
} from './slices/meshtasticSlice';
|
||||
} from '@core/slices/meshtasticSlice';
|
||||
import { Protobuf, SettingsManager, Types } from '@meshtastic/meshtasticjs';
|
||||
import { About } from '@pages/About';
|
||||
import { Messages } from '@pages/Messages';
|
||||
import { Nodes } from '@pages/Nodes/Index';
|
||||
import { Settings } from '@pages/settings/Index';
|
||||
|
||||
const App = (): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
const route = useRoute();
|
||||
|
||||
const myNodeInfo = useAppSelector((state) => state.meshtastic.myNodeInfo);
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||
const hostOverrideEnabled = useAppSelector(
|
||||
(state) => state.meshtastic.hostOverrideEnabled,
|
||||
);
|
||||
const hostOverride = useAppSelector((state) => state.meshtastic.hostOverride);
|
||||
|
||||
React.useEffect(() => {
|
||||
SettingsManager.debugMode = Protobuf.LogRecord_Level.TRACE;
|
||||
|
||||
connection.connect({
|
||||
address:
|
||||
import.meta.env.NODE_ENV === 'production'
|
||||
? window.location.hostname
|
||||
: import.meta.env.SNOWPACK_PUBLIC_DEVICE_IP,
|
||||
void connection.connect({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
address: hostOverrideEnabled
|
||||
? hostOverride
|
||||
: import.meta.env.NODE_ENV === 'production'
|
||||
? window.location.hostname
|
||||
: import.meta.env.SNOWPACK_PUBLIC_DEVICE_IP ??
|
||||
'http://meshtastic.local',
|
||||
receiveBatchRequests: false,
|
||||
tls: false,
|
||||
fetchInterval: 2000,
|
||||
});
|
||||
}, []);
|
||||
}, [hostOverrideEnabled, hostOverride]);
|
||||
|
||||
React.useEffect(() => {
|
||||
connection.onDeviceStatus.subscribe((status) => {
|
||||
@@ -40,6 +61,9 @@ const App = (): JSX.Element => {
|
||||
if (status === Types.DeviceStatusEnum.DEVICE_CONFIGURED) {
|
||||
dispatch(setReady(true));
|
||||
}
|
||||
if (status === Types.DeviceStatusEnum.DEVICE_DISCONNECTED) {
|
||||
dispatch(setReady(false));
|
||||
}
|
||||
});
|
||||
|
||||
connection.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||
@@ -70,12 +94,71 @@ const App = (): JSX.Element => {
|
||||
connection.onMeshHeartbeat.subscribe((date) =>
|
||||
dispatch(setLastMeshInterraction(date.getTime())),
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
connection.onTextPacket.subscribe((message) => {
|
||||
dispatch(
|
||||
addMessage({
|
||||
message: message,
|
||||
ack: message.packet.from !== myNodeInfo.myNodeNum,
|
||||
isSender: message.packet.from === myNodeInfo.myNodeNum,
|
||||
received: new Date(message.packet.rxTime),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
connection.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.packet.payloadVariant.oneofKind === 'decoded') {
|
||||
dispatch(
|
||||
ackMessage(routingPacket.packet.payloadVariant.decoded.requestId),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
connection.onDeviceStatus.cancelAll();
|
||||
connection.onMyNodeInfo.cancelAll();
|
||||
connection.onNodeInfoPacket.cancelAll();
|
||||
connection.onAdminPacket.cancelAll();
|
||||
connection.onMeshHeartbeat.cancelAll();
|
||||
connection.onTextPacket.cancelAll();
|
||||
connection.onRoutingPacket.cancelAll();
|
||||
};
|
||||
}, [dispatch, myNodeInfo.myNodeNum]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen">
|
||||
<Header />
|
||||
<Main />
|
||||
<div
|
||||
className={`h-screen w-screen ${darkMode ? 'dark rs-theme-dark' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col h-full bg-gray-200 dark:bg-primaryDark">
|
||||
<div className="flex flex-shrink-0 overflow-hidden bg-primary dark:bg-primary">
|
||||
<div className="w-full overflow-hidden bg-white border-b md:mt-12 md:mx-8 md:pt-4 md:pb-3 md:rounded-t-xl dark:border-gray-600 md:shadow-md dark:bg-primaryDark">
|
||||
<div className="flex items-center justify-between h-16 px-4 md:px-6">
|
||||
<div className="hidden md:flex">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<MobileNavToggle />
|
||||
<div className="flex items-center space-x-2">
|
||||
<DeviceStatusDropdown />
|
||||
{/* <LanguageDropdown /> */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<Navigation className="hidden md:flex" />
|
||||
</div>
|
||||
</div>
|
||||
<MobileNav />
|
||||
|
||||
<div className="flex flex-grow w-full min-h-0 md:px-8 md:mb-8">
|
||||
<div className="flex w-full bg-gray-100 md:shadow-xl md:overflow-hidden dark:bg-secondaryDark md:rounded-b-xl">
|
||||
{route.name === 'messages' && <Messages />}
|
||||
{route.name === 'nodes' && <Nodes />}
|
||||
{route.name === 'settings' && <Settings />}
|
||||
{route.name === 'about' && <About />}
|
||||
{route.name === false && 'Not Found'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
63
src/Main.tsx
63
src/Main.tsx
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Types } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { ChatMessage } from './components/ChatMessage';
|
||||
import { MessageBox } from './components/MessageBox';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { connection } from './connection';
|
||||
|
||||
export const Main = (): JSX.Element => {
|
||||
const [messages, setMessages] = React.useState<
|
||||
{ message: Types.TextPacket; ack: boolean }[]
|
||||
>([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
connection.onTextPacket.subscribe((message) => {
|
||||
setMessages((messages) => [
|
||||
...messages,
|
||||
{ message: message, ack: false },
|
||||
]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
connection.onRoutingPacket.subscribe((routingPacket) => {
|
||||
setMessages(
|
||||
messages.map((message) => {
|
||||
return routingPacket.packet.payloadVariant.oneofKind === 'decoded' &&
|
||||
message.message.packet.id ===
|
||||
routingPacket.packet.payloadVariant.decoded.requestId
|
||||
? {
|
||||
ack: true,
|
||||
message: message.message,
|
||||
}
|
||||
: message;
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row flex-grow m-3 space-y-2 md:space-y-0 space-x-0 md:space-x-2">
|
||||
<div className="flex flex-col flex-grow container mx-auto">
|
||||
<div className="flex flex-col flex-grow py-6 space-y-2">
|
||||
{messages.length ? (
|
||||
messages.map((message, Main) => (
|
||||
<ChatMessage key={Main} message={message} />
|
||||
))
|
||||
) : (
|
||||
<div className="m-auto text-2xl text-gray-500">
|
||||
{t('placeholder.no_messages')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MessageBox />
|
||||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DotsCircleHorizontalIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import type { Types } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { useAppSelector } from '../hooks/redux';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: { message: Types.TextPacket; ack: boolean };
|
||||
}
|
||||
|
||||
export const ChatMessage = (props: ChatMessageProps): JSX.Element => {
|
||||
const myNodeInfo = useAppSelector((state) => state.meshtastic.myNodeInfo);
|
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||
|
||||
const node = nodes.find((node) => {
|
||||
return node.num === props.message.message.packet.from;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-end">
|
||||
<Avatar
|
||||
size={40}
|
||||
name={node?.user?.longName ?? 'UNK'}
|
||||
variant="beam"
|
||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
|
||||
/>
|
||||
<div className="flex flex-col container px-2 items-start">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="m-auto p-3 text-gray-500">Loading</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`px-4 py-2 rounded-3xl shadow-md ${
|
||||
props.message.message.packet.from !== myNodeInfo.myNodeNum
|
||||
? 'bg-gray-300'
|
||||
: 'bg-green-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex text-xs text-gray-500 space-x-1">
|
||||
<div className="font-medium">{node?.user?.longName ?? 'UNK'}</div>
|
||||
<p>-</p>
|
||||
<div className="underline">
|
||||
{new Date(
|
||||
props.message.message.packet.rxTime > 0
|
||||
? props.message.message.packet.rxTime
|
||||
: Date.now(),
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span className="inline-block">{props.message.message.data}</span>
|
||||
{node?.num === myNodeInfo.myNodeNum &&
|
||||
(props.message.ack ? (
|
||||
<CheckCircleIcon className="my-auto w-5 h-5" />
|
||||
) : (
|
||||
<DotsCircleHorizontalIcon className="my-auto animate-pulse w-5 h-5" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
DeviceMobileIcon,
|
||||
StatusOfflineIcon,
|
||||
StatusOnlineIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import { Types } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { useAppSelector } from '../hooks/redux';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
const deviceStatus = useAppSelector((state) => state.meshtastic.deviceStatus);
|
||||
const ready = useAppSelector((state) => state.meshtastic.ready);
|
||||
const lastMeshInterraction = useAppSelector(
|
||||
(state) => state.meshtastic.lastMeshInterraction,
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="select-none w-full shadow-md">
|
||||
<div className="flex w-full container mx-auto justify-between px-6 py-4">
|
||||
<Logo />
|
||||
<div></div>
|
||||
|
||||
<div className="flex space-x-2 items-center">
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full ${
|
||||
new Date(lastMeshInterraction) < new Date(Date.now() - 40000)
|
||||
? 'bg-red-400 animate-pulse'
|
||||
: new Date(lastMeshInterraction) <
|
||||
new Date(Date.now() - 20000)
|
||||
? 'bg-yellow-400 animate-pulse'
|
||||
: 'bg-green-400'
|
||||
}`}
|
||||
></div>
|
||||
{new Date(lastMeshInterraction) > new Date(Date.now() - 40000) ? (
|
||||
<StatusOnlineIcon className="m-auto ml-1 h-5 w-5" />
|
||||
) : (
|
||||
<StatusOfflineIcon className="m-auto ml-1 h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full ${
|
||||
deviceStatus <= Types.DeviceStatusEnum.DEVICE_DISCONNECTED
|
||||
? 'bg-red-400 animate-pulse'
|
||||
: deviceStatus <= Types.DeviceStatusEnum.DEVICE_CONFIGURING &&
|
||||
!ready
|
||||
? 'bg-yellow-400 animate-pulse'
|
||||
: ready
|
||||
? 'bg-green-400'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
></div>
|
||||
<DeviceMobileIcon className="m-auto ml-1 w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Logo = (): JSX.Element => {
|
||||
return (
|
||||
<svg
|
||||
height="30"
|
||||
width="200"
|
||||
viewBox="0 0 1115 116"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
fillRule: 'evenodd',
|
||||
clipRule: 'evenodd',
|
||||
strokeLinejoin: 'round',
|
||||
strokeMiterlimit: 1.5,
|
||||
}}
|
||||
>
|
||||
<g transform="matrix(1.05081,0,0,0.602459,-74.3378,-345.171)">
|
||||
<g>
|
||||
<g transform="matrix(0.973838,0,0,1.69858,-1.56777,-229.112)">
|
||||
<path
|
||||
d="M81.582,577.266L148.536,478.804"
|
||||
style={{ fill: 'none', stroke: 'black', strokeWidth: '14.11px' }}
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(0.961342,0,0,1.67678,882.45,-216.54)">
|
||||
<path
|
||||
d="M81.582,577.266L148.536,478.804"
|
||||
style={{ fill: 'none', stroke: 'black', strokeWidth: '14.86px' }}
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(12.0448,0,0,21.0595,-7445.39,-7644.88)">
|
||||
<text
|
||||
x="640.988px"
|
||||
y="399.072px"
|
||||
style={{
|
||||
fontFamily: 'ArialMT, Arial, sans-serif',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
ESHT
|
||||
</text>
|
||||
</g>
|
||||
<g transform="matrix(0.977299,0,0,1.70462,-43.6432,50.5292)">
|
||||
<path
|
||||
d="M187.032,410.85L250.896,317.192L314.907,410.702"
|
||||
style={{ fill: 'none', stroke: 'black', strokeWidth: '13.85px' }}
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(0.977299,0,0,1.70462,468.182,53.0697)">
|
||||
<path
|
||||
d="M187.032,410.85L250.896,317.192L314.907,410.702"
|
||||
style={{ fill: 'none', stroke: 'black', strokeWidth: '13.85px' }}
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(0.571939,0,0,1,784.482,759.924)">
|
||||
<text
|
||||
x="0px"
|
||||
y="0px"
|
||||
style={{
|
||||
fontFamily: 'ArialMT, Arial, sans-serif',
|
||||
fontSize: '252.715px',
|
||||
}}
|
||||
>
|
||||
ST
|
||||
</text>
|
||||
</g>
|
||||
<g transform="matrix(0.571939,0,0,1,1030.51,760.498)">
|
||||
<text
|
||||
x="0px"
|
||||
y="0px"
|
||||
style={{
|
||||
fontFamily: 'ArialMT, Arial, sans-serif',
|
||||
fontSize: '252.715px',
|
||||
}}
|
||||
>
|
||||
C
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MenuIcon, PaperAirplaneIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { connection } from '../connection';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks/redux';
|
||||
import { toggleSidebar } from '../slices/appSlice';
|
||||
|
||||
export const MessageBox = (): JSX.Element => {
|
||||
const ready = useAppSelector((state) => state.meshtastic.ready);
|
||||
const [currentMessage, setCurrentMessage] = React.useState('');
|
||||
const sendMessage = () => {
|
||||
if (ready) {
|
||||
connection.sendText(currentMessage, undefined, true);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<div className="flex text-lg font-medium space-x-2 md:space-x-0 w-full">
|
||||
<motion.button
|
||||
initial={{}}
|
||||
whileHover={{
|
||||
backgroundColor: 'rgba(229, 231, 235)',
|
||||
}}
|
||||
className="flex h-14 w-14 text-xl hover:text-gray-500 text-gray-400 rounded-full border shadow-md focus:outline-none cursor-pointer md:hidden"
|
||||
onClick={() => {
|
||||
dispatch(toggleSidebar());
|
||||
}}
|
||||
>
|
||||
<MenuIcon className="m-auto h-6 w-6" />
|
||||
</motion.button>
|
||||
<form
|
||||
className="flex flex-wrap relative w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}}
|
||||
>
|
||||
{ready}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`${t('placeholder.no_messages')}...`}
|
||||
disabled={!ready}
|
||||
value={currentMessage}
|
||||
onChange={(e) => {
|
||||
setCurrentMessage(e.target.value);
|
||||
}}
|
||||
className={`p-3 placeholder-gray-400 text-gray-700 relative rounded-3xl border shadow-md focus:outline-none w-full pr-10 ${
|
||||
ready ? 'cursor-text' : 'cursor-not-allowed'
|
||||
}`}
|
||||
/>
|
||||
<span className="flex z-10 h-full text-gray-400 absolute w-8 right-1">
|
||||
<PaperAirplaneIcon
|
||||
onClick={sendMessage}
|
||||
className={`text-xl hover:text-gray-500 h-6 w-6 my-auto ${
|
||||
ready ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { useAppSelector } from '../hooks/redux';
|
||||
import { Channels } from './Sidebar/Channels/Index';
|
||||
import { Device } from './Sidebar/Device/Index';
|
||||
import { Nodes } from './Sidebar/Nodes/Index';
|
||||
import { UI } from './Sidebar/UI/Index';
|
||||
|
||||
export const Sidebar = (): JSX.Element => {
|
||||
const sidebarOpen = useAppSelector((state) => state.app.sidebarOpen);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
height: 0,
|
||||
}}
|
||||
animate={{
|
||||
height: 'auto',
|
||||
}}
|
||||
exit={{
|
||||
height: 0,
|
||||
}}
|
||||
className="flex flex-col rounded-3xl md:ml-0 shadow-md border w-full md:max-w-sm"
|
||||
>
|
||||
<Nodes />
|
||||
<Device />
|
||||
<Channels />
|
||||
<div className="flex-grow border-b"></div>
|
||||
<UI />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/outline';
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface ChannelProps {
|
||||
channel: Protobuf.Channel;
|
||||
}
|
||||
|
||||
export const Channel = (props: ChannelProps): JSX.Element => {
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex bg-gray-50 w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer">
|
||||
<div className="flex ml-4">
|
||||
{open ? (
|
||||
<ChevronDownIcon className="my-auto w-5 h-5 mr-2" />
|
||||
) : (
|
||||
<ChevronRightIcon className="my-auto w-5 h-5 mr-2" />
|
||||
)}
|
||||
{props.channel.index} -{' '}
|
||||
{Protobuf.Channel_Role[props.channel.role]}
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
<div className="w-full bg-gray-100 px-2">
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Bandwidth:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.bandwidth}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Channel Number:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.channelNum}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Coding Rate:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.codingRate}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>ID:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Modem Config:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.modemConfig
|
||||
? Protobuf.ChannelSettings_ModemConfig[
|
||||
props.channel.settings.modemConfig
|
||||
]
|
||||
: null}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Name:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.name}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>PSK:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.psk.toLocaleString()}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Spread Factor:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.spreadFactor}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Tx Power:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.txPower}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Uplink:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.uplinkEnabled ? 'true' : 'false'}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex justify-between border-b hover:bg-gray-200">
|
||||
<p>Downlink:</p>
|
||||
<code className="bg-gray-200 rounded-full px-2">
|
||||
{props.channel.settings?.downlinkEnabled ? 'true' : 'false'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { useAppSelector } from '../../../hooks/redux';
|
||||
import { Channel } from './Channel';
|
||||
|
||||
export const ChannelList = (): JSX.Element => {
|
||||
const channels = useAppSelector((state) => state.meshtastic.channels);
|
||||
|
||||
return (
|
||||
<>
|
||||
{channels.map((channel, index) => {
|
||||
if (channel.role !== Protobuf.Channel_Role.DISABLED)
|
||||
return <Channel key={index} channel={channel} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { HashtagIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { Dropdown } from '../../basic/Dropdown';
|
||||
import { ChannelList } from './ChannelList';
|
||||
|
||||
export const Channels = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dropdown
|
||||
icon={<HashtagIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
|
||||
title={t('settings.channel')}
|
||||
content={<ChannelList />}
|
||||
fallbackMessage={'Loading...'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AdjustmentsIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { Dropdown } from '../../basic/Dropdown';
|
||||
import { Settings } from './Settings';
|
||||
|
||||
export const Device = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dropdown
|
||||
icon={<AdjustmentsIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
|
||||
title={t('settings.device')}
|
||||
content={<Settings />}
|
||||
fallbackMessage={'Loading...'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SaveIcon } from '@heroicons/react/outline';
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { connection } from '../../../connection';
|
||||
import { useAppSelector } from '../../../hooks/redux';
|
||||
|
||||
export const Settings = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const preferences = useAppSelector((state) => state.meshtastic.preferences);
|
||||
|
||||
const { register, handleSubmit } =
|
||||
useForm<Protobuf.RadioConfig_UserPreferences>({
|
||||
defaultValues: preferences,
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => connection.setPreferences(data));
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex bg-gray-50 whitespace-nowrap p-3 justify-between border-b">
|
||||
<div className="my-auto">{t('strings.device_region')}</div>
|
||||
<div className="flex shadow-md rounded-3xl ml-2">
|
||||
<select
|
||||
{...register('region', {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
>
|
||||
<option value={Protobuf.RegionCode.ANZ}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.ANZ]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.CN}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.CN]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.EU433}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.EU433]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.EU865}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.EU865]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.JP}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.JP]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.KR}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.KR]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.TW}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.TW]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.US}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.US]}
|
||||
</option>
|
||||
<option value={Protobuf.RegionCode.Unset}>
|
||||
{Protobuf.RegionCode[Protobuf.RegionCode.Unset]}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex bg-gray-50 whitespace-nowrap p-3 justify-between border-b">
|
||||
<div className="my-auto">{t('strings.wifi_ssid')}</div>
|
||||
<div className="flex shadow-md rounded-3xl ml-2">
|
||||
<input {...register('wifiSsid', {})} type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex bg-gray-50 whitespace-nowrap p-3 justify-between border-b">
|
||||
<div className="my-auto">{t('strings.wifi_psk')}</div>
|
||||
<div className="flex shadow-md rounded-3xl ml-2">
|
||||
<input {...register('wifiPassword', {})} type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex bg-gray-100 group p-1 cursor-pointer hover:bg-gray-200 border-b">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex m-auto font-medium group-hover:text-gray-700"
|
||||
>
|
||||
<SaveIcon className="m-auto mr-2 group-hover:text-gray-700 w-5 h-5" />
|
||||
{t('strings.save_changes')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { UsersIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { Dropdown } from '../../basic/Dropdown';
|
||||
import { NodeList } from './NodeList';
|
||||
|
||||
export const Nodes = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dropdown
|
||||
icon={<UsersIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
|
||||
title={t('strings.nodes')}
|
||||
content={<NodeList />}
|
||||
fallbackMessage={t('placeholder.no_messages')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
LightningBoltIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { useAppSelector } from '../../../hooks/redux';
|
||||
|
||||
export interface NodeProps {
|
||||
node: Protobuf.NodeInfo;
|
||||
}
|
||||
|
||||
export const Node = (props: NodeProps): JSX.Element => {
|
||||
const myNodeInfo = useAppSelector((state) => state.meshtastic.myNodeInfo);
|
||||
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex bg-gray-50 w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer">
|
||||
<div className="flex ml-4">
|
||||
{open ? (
|
||||
<ChevronDownIcon className="my-auto w-5 h-5 mr-2" />
|
||||
) : (
|
||||
<ChevronRightIcon className="my-auto w-5 h-5 mr-2" />
|
||||
)}
|
||||
<div className="relative">
|
||||
{props.node.num === myNodeInfo.myNodeNum ? (
|
||||
<FlagIcon className="absolute -right-1 -top-2 text-yellow-500 my-auto w-4 h-4" />
|
||||
) : null}
|
||||
<Avatar
|
||||
size={30}
|
||||
name={props.node.user?.longName ?? 'Unknown'}
|
||||
variant="beam"
|
||||
colors={[
|
||||
'#213435',
|
||||
'#46685B',
|
||||
'#648A64',
|
||||
'#A6B985',
|
||||
'#E1E3AC',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{props.node.user?.longName}
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
<div className="border-b bg-gray-100 px-2">
|
||||
<p>{props.node.snr}</p>
|
||||
<p>
|
||||
{`Last heard: ${
|
||||
props.node?.lastHeard
|
||||
? new Date(props.node.lastHeard).toLocaleString()
|
||||
: 'Unknown'
|
||||
}`}{' '}
|
||||
{}
|
||||
</p>
|
||||
<div className="flex">
|
||||
<GlobeIcon className="my-auto mr-2 w-5 h-5" />
|
||||
<p>
|
||||
{props.node.position?.latitudeI &&
|
||||
props.node.position?.longitudeI
|
||||
? `${props.node.position.latitudeI / 1e7},
|
||||
${props.node.position.longitudeI / 1e7}`
|
||||
: 'Unknown'}
|
||||
, El:
|
||||
{props.node.position?.altitude}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<ClockIcon className="my-auto mr-2 w-5 h-5" />
|
||||
<p>{props.node.position?.time}</p>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<LightningBoltIcon className="my-auto mr-2 w-5 h-5" />
|
||||
<p>{props.node.position?.batteryLevel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useAppSelector } from '../../../hooks/redux';
|
||||
import { Node } from './Node';
|
||||
|
||||
export const NodeList = (): JSX.Element => {
|
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node, index) => (
|
||||
<Node key={index} node={node} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CogIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { Dropdown } from '../../basic/Dropdown';
|
||||
import { Translations } from './Translations';
|
||||
|
||||
export const UI = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
icon={<CogIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
|
||||
title={t('settings.ui')}
|
||||
content={<Translations />}
|
||||
fallbackMessage={'Loading...'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Br, Jp, Us } from 'react-flags-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/outline';
|
||||
|
||||
export const Translations = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex bg-gray-50 w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer">
|
||||
<div className="flex ml-4">
|
||||
{open ? (
|
||||
<ChevronDownIcon className="my-auto w-5 h-5 mr-2" />
|
||||
) : (
|
||||
<ChevronRightIcon className="my-auto w-5 h-5 mr-2" />
|
||||
)}
|
||||
{t('strings.language')}
|
||||
{/* <div className="my-auto">
|
||||
{language === LanguageEnum.ENGLISH ? (
|
||||
<Us className="ml-2 w-8" />
|
||||
) : language === LanguageEnum.JAPANESE ? (
|
||||
<Jp className="ml-2 w-8" />
|
||||
) : language === LanguageEnum.PORTUGUESE ? (
|
||||
<Br className="ml-2 w-8" />
|
||||
) : null}
|
||||
</div> */}
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
<div
|
||||
className="flex bg-gray-100 hover:bg-gray-200 cursor-pointer justify-between p-2"
|
||||
onClick={() => {
|
||||
// setLanguage(LanguageEnum.ENGLISH);
|
||||
}}
|
||||
>
|
||||
English <Us className="w-8 my-auto" />
|
||||
</div>
|
||||
<div
|
||||
className="flex bg-gray-100 hover:bg-gray-200 cursor-pointer justify-between p-2"
|
||||
onClick={() => {
|
||||
// setLanguage(LanguageEnum.PORTUGUESE);
|
||||
}}
|
||||
>
|
||||
Português <Br className="w-8 my-auto" />
|
||||
</div>
|
||||
<div
|
||||
className="flex bg-gray-100 hover:bg-gray-200 cursor-pointer justify-between p-2"
|
||||
onClick={() => {
|
||||
// setLanguage(LanguageEnum.JAPANESE);
|
||||
}}
|
||||
>
|
||||
日本語 <Jp className="w-8 my-auto" />
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/outline';
|
||||
|
||||
interface DropdownProps {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
content: JSX.Element;
|
||||
fallbackMessage: string;
|
||||
}
|
||||
|
||||
export const Dropdown = (props: DropdownProps): JSX.Element => {
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer first:rounded-t-3xl last:rounded-b-3xl">
|
||||
<div className="flex">
|
||||
<motion.div
|
||||
className="my-auto mr-2"
|
||||
variants={{
|
||||
initial: { rotate: -90 },
|
||||
animate: {
|
||||
rotate: 0,
|
||||
},
|
||||
}}
|
||||
initial="initial"
|
||||
animate={open ? 'animate' : 'initial'}
|
||||
>
|
||||
<ChevronDownIcon className="w-5 h-5" />
|
||||
</motion.div>
|
||||
{props.icon}
|
||||
{props.title}
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Disclosure.Panel
|
||||
as={motion.div}
|
||||
initial={{
|
||||
height: 0,
|
||||
}}
|
||||
animate={{
|
||||
height: 'auto',
|
||||
}}
|
||||
exit={{
|
||||
height: 0,
|
||||
}}
|
||||
className="shadow-inner"
|
||||
>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="m-auto p-3 text-gray-500">
|
||||
{props.fallbackMessage}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.content}
|
||||
</React.Suspense>
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Switch } from '@headlessui/react';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const ToggleSwitch = (props: ToggleSwitchProps): JSX.Element => {
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActive(props.active);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={active}
|
||||
onChange={setActive}
|
||||
className={`w-12 h-6 flex items-center bg-gray-300 rounded-full p-1 duration-300 ease-in-out my-auto ${
|
||||
active ? 'bg-green-400' : null
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`bg-white w-4 h-4 rounded-full shadow-md transform duration-300 ease-in-out ${
|
||||
active ? 'translate-x-6' : null
|
||||
}`}
|
||||
></span>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
56
src/components/chat/Message.tsx
Normal file
56
src/components/chat/Message.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
|
||||
export interface MessageProps {
|
||||
message: string;
|
||||
ack: boolean;
|
||||
isSender: boolean;
|
||||
rxTime: Date;
|
||||
senderName: string;
|
||||
}
|
||||
|
||||
export const Message = ({
|
||||
message,
|
||||
ack,
|
||||
isSender,
|
||||
rxTime,
|
||||
senderName,
|
||||
}: MessageProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={`flex space-x-2 ${
|
||||
!isSender ? 'ml-auto flex-row-reverse' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`shadow-md rounded-full mt-auto ${!isSender ? 'ml-2' : ''}`}
|
||||
>
|
||||
<Avatar
|
||||
size={30}
|
||||
name={senderName ?? 'UNK'}
|
||||
variant="beam"
|
||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`relative max-w-3/4 px-3 py-2 rounded-t-lg ${
|
||||
isSender
|
||||
? 'bg-gray-500 text-gray-50 rounded-br-lg'
|
||||
: 'bg-primary text-blue-50 rounded-bl-lg'
|
||||
} ${ack ? 'animate-none' : 'animate-pulse'}`}
|
||||
>
|
||||
<div className="leading-5 min-w-4">{message}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{senderName}</div>
|
||||
</div>
|
||||
<div className="mt-auto mb-4 mr-3 text-xs font-medium text-secondary dark:text-gray-200">
|
||||
{rxTime.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
src/components/chat/MessageBar.tsx
Normal file
58
src/components/chat/MessageBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppSelector } from '@app/hooks/redux';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { Input } from '@components/generic/Input';
|
||||
import { connection } from '@core/connection';
|
||||
import {
|
||||
EmojiHappyIcon,
|
||||
PaperAirplaneIcon,
|
||||
PaperClipIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
|
||||
export const MessageBar = (): JSX.Element => {
|
||||
const ready = useAppSelector((state) => state.meshtastic.ready);
|
||||
const [currentMessage, setCurrentMessage] = React.useState('');
|
||||
const sendMessage = (): void => {
|
||||
if (ready) {
|
||||
void connection.sendText(currentMessage, undefined, true);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full p-4 mx-auto space-x-2 text-gray-500 bg-gray-50 dark:bg-transparent dark:text-gray-400">
|
||||
<div className="flex w-full max-w-4xl">
|
||||
<div className="flex">
|
||||
<Button icon={<EmojiHappyIcon className="w-5 h-5" />} circle />
|
||||
<Button icon={<PaperClipIcon className="w-5 h-5" />} circle />
|
||||
</div>
|
||||
<form
|
||||
className="flex w-full space-x-2"
|
||||
onSubmit={(e): void => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
minLength={2}
|
||||
placeholder={`${t('placeholder.message')}...`}
|
||||
disabled={!ready}
|
||||
value={currentMessage}
|
||||
onChange={(e): void => {
|
||||
setCurrentMessage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<PaperAirplaneIcon className="w-5 h-5" />}
|
||||
type="submit"
|
||||
circle
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
src/components/generic/Blur.tsx
Normal file
33
src/components/generic/Blur.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
||||
|
||||
interface LocalBlurProps {
|
||||
disableOnMd?: boolean;
|
||||
}
|
||||
|
||||
export type BlurProps = LocalBlurProps & DefaultDivProps;
|
||||
|
||||
export const Blur = ({
|
||||
disableOnMd,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: BlurProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 z-10 w-full h-full transition-opacity ${
|
||||
disableOnMd ? 'md:hidden' : 'test'
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`absolute inset-0 w-full h-full backdrop-filter backdrop-blur-sm ${
|
||||
disableOnMd ? 'md:hidden' : 'test'
|
||||
}`}
|
||||
tabIndex={0}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
src/components/generic/Button.tsx
Normal file
50
src/components/generic/Button.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
type DefaultButtonProps = JSX.IntrinsicElements['button'];
|
||||
|
||||
interface LocalButtonProps {
|
||||
icon?: JSX.Element;
|
||||
circle?: boolean;
|
||||
active?: boolean;
|
||||
border?: boolean;
|
||||
}
|
||||
|
||||
export type ButtonProps = LocalButtonProps & DefaultButtonProps;
|
||||
|
||||
export const Button = ({
|
||||
icon,
|
||||
circle,
|
||||
className,
|
||||
active,
|
||||
border,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
className={`items-center select-none flex dark:text-white ${
|
||||
active && !disabled ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
} ${
|
||||
circle ? 'rounded-full h-10 w-10' : 'rounded-md p-3 space-x-3 text-sm'
|
||||
} ${
|
||||
disabled
|
||||
? 'cursor-not-allowed dark:bg-primaryDark bg-white'
|
||||
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md'
|
||||
} ${border ? 'border dark:border-gray-600' : ''} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<div
|
||||
className={`text-gray-500 dark:text-gray-400 ${
|
||||
circle ? 'mx-auto' : ''
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
35
src/components/generic/Drawer.tsx
Normal file
35
src/components/generic/Drawer.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Blur } from '@components/generic/Blur';
|
||||
|
||||
type DefaultAsideProps = JSX.IntrinsicElements['aside'];
|
||||
|
||||
interface LocalDrawerProps {
|
||||
open: boolean;
|
||||
permenant?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
export type DrawerProps = LocalDrawerProps & DefaultAsideProps;
|
||||
|
||||
export const Drawer = ({
|
||||
open,
|
||||
permenant,
|
||||
onClose,
|
||||
children,
|
||||
...props
|
||||
}: DrawerProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
{open && <Blur disableOnMd={true} onClick={onClose} />}
|
||||
|
||||
<aside
|
||||
className={`transform top-0 left-0 bg-white dark:bg-secondaryDark shadow-md max-w-xs w-full border-r dark:border-gray-600 h-full overflow-auto ease-in-out transition-all duration-300 z-30 ${
|
||||
permenant ? '' : 'absolute'
|
||||
} ${open ? 'translate-x-0' : '-translate-x-full'}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
50
src/components/generic/Input.tsx
Normal file
50
src/components/generic/Input.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
type DefaultInputProps = JSX.IntrinsicElements['input'];
|
||||
|
||||
interface LocalInputProps {
|
||||
icon?: JSX.Element;
|
||||
label?: string;
|
||||
valid?: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
export type InputProps = LocalInputProps & DefaultInputProps;
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
function Input(
|
||||
{ icon, label, valid, validationMessage, id, ...props }: InputProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium dark:text-white"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute inset-y-0 left-0 flex items-center px-3 pointer-events-none">
|
||||
{React.cloneElement(icon, {
|
||||
className: 'w-5 h-5 text-gray-500 dark:text-gray-600',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={`block w-full h-11 rounded-md border shadow-sm focus:outline-none focus:border-primary dark:focus:border-primary bg-white dark:bg-secondaryDark dark:border-gray-600 dark:text-white ${
|
||||
icon ? 'pl-9' : 'pl-2'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{!valid && (
|
||||
<div className="text-sm text-gray-600">{validationMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
82
src/components/generic/Select.tsx
Normal file
82
src/components/generic/Select.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Listbox } from '@headlessui/react';
|
||||
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid';
|
||||
|
||||
export interface SelectProps {
|
||||
label: string;
|
||||
options: {
|
||||
name: string;
|
||||
value: string;
|
||||
icon: JSX.Element;
|
||||
}[];
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const Select = ({
|
||||
label,
|
||||
options,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: SelectProps): JSX.Element => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label htmlFor={id} className="block text-sm font-medium dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="relative w-full text-left bg-white border rounded-md shadow-sm h-11 focus:outline-none focus:border-primary dark:focus:border-primary dark:bg-secondaryDark dark:border-gray-600 dark:text-white">
|
||||
<span className="block truncate">{value}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon
|
||||
className="w-5 h-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Listbox.Options className="absolute w-full bg-white border rounded-md shadow-sm focus:outline-none dark:bg-secondaryDark dark:border-gray-600 dark:text-white">
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
className={({ active }): string =>
|
||||
`cursor-default select-none relative py-2 pl-10 pr-4 first:rounded-t-md last:rounded-b-md dark:text-white ${
|
||||
active ? 'bg-gray-200 dark:bg-primaryDark' : 'text-gray-900'
|
||||
}`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected, active }): JSX.Element => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-medium' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-amber-600' : 'text-amber-600'
|
||||
}
|
||||
absolute inset-y-0 left-0 flex items-center pl-3`}
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
src/components/generic/SidebarItem.tsx
Normal file
33
src/components/generic/SidebarItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
||||
|
||||
interface LocalSidebarItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
export type SidebarItemProps = LocalSidebarItemProps & DefaultDivProps;
|
||||
|
||||
export const SidebarItem = ({
|
||||
title,
|
||||
description,
|
||||
selected,
|
||||
icon,
|
||||
}: SidebarItemProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={`flex p-5 cursor-pointer select-none dark:hover:bg-primaryDark ${
|
||||
selected ? 'bg-gray-200 dark:bg-primaryDark' : 'dark:bg-secondaryDark'
|
||||
}`}
|
||||
>
|
||||
<div className="text-gray-500 dark:text-gray-400">{icon}</div>
|
||||
<div className="ml-3 text-left">
|
||||
<div className="font-medium text-left">{title}</div>
|
||||
<div className="mt-0.5 text-gray-400 text-sm">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
src/components/menu/Logo.tsx
Normal file
18
src/components/menu/Logo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Logo = (): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
title="Logo"
|
||||
className="w-16 dark:hidden"
|
||||
src="Mesh_Logo_Black.svg"
|
||||
/>
|
||||
<img
|
||||
title="Logo"
|
||||
className="hidden w-16 dark:flex"
|
||||
src="Mesh_Logo_White.svg"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
34
src/components/menu/MobileNav.tsx
Normal file
34
src/components/menu/MobileNav.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
|
||||
import { Drawer } from '@components/generic/Drawer';
|
||||
import { closeMobileNav } from '@core/slices/appSlice';
|
||||
|
||||
import { Logo } from './Logo';
|
||||
import { Navigation } from './Navigation';
|
||||
|
||||
export const MobileNav = (): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const mobileNavOpen = useAppSelector((state) => state.app.mobileNavOpen);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={mobileNavOpen}
|
||||
onClose={(): void => {
|
||||
dispatch(closeMobileNav());
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="m-auto my-6">
|
||||
<Logo />
|
||||
</div>
|
||||
<Navigation
|
||||
onClick={(): void => {
|
||||
dispatch(closeMobileNav());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
69
src/components/menu/Navigation.tsx
Normal file
69
src/components/menu/Navigation.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { routes, useRoute } from '@core/router';
|
||||
import {
|
||||
AnnotationIcon,
|
||||
CogIcon,
|
||||
InformationCircleIcon,
|
||||
ViewGridIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
|
||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
||||
|
||||
export type NavigationProps = DefaultDivProps;
|
||||
|
||||
export const Navigation = ({
|
||||
onClick,
|
||||
className,
|
||||
...props
|
||||
}: NavigationProps): JSX.Element => {
|
||||
const route = useRoute();
|
||||
return (
|
||||
<div
|
||||
className={`h-16 px-4 md:space-x-2 space-y-2 md:space-y-0 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div onClick={onClick}>
|
||||
<Button
|
||||
icon={<AnnotationIcon className="w-6 h-6" />}
|
||||
active={route.name === 'messages'}
|
||||
className="w-full md:w-auto"
|
||||
{...routes.messages().link}
|
||||
>
|
||||
Messages
|
||||
</Button>
|
||||
</div>
|
||||
<div onClick={onClick}>
|
||||
<Button
|
||||
icon={<ViewGridIcon className="w-6 h-6" />}
|
||||
className="w-full md:w-auto"
|
||||
active={route.name === 'nodes'}
|
||||
{...routes.nodes().link}
|
||||
>
|
||||
Nodes
|
||||
</Button>
|
||||
</div>
|
||||
<div onClick={onClick}>
|
||||
<Button
|
||||
icon={<CogIcon className="w-6 h-6" />}
|
||||
className="w-full md:w-auto"
|
||||
active={route.name === 'settings'}
|
||||
{...routes.settings().link}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
<div onClick={onClick}>
|
||||
<Button
|
||||
icon={<InformationCircleIcon className="w-6 h-6" />}
|
||||
className="w-full md:w-auto"
|
||||
active={route.name === 'about'}
|
||||
{...routes.about().link}
|
||||
>
|
||||
About
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/components/menu/buttons/DeviceStatusDropdown.tsx
Normal file
20
src/components/menu/buttons/DeviceStatusDropdown.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useAppSelector } from '@app/hooks/redux';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { SwitchVerticalIcon } from '@heroicons/react/outline';
|
||||
|
||||
export const DeviceStatusDropdown = (): JSX.Element => {
|
||||
const ready = useAppSelector((state) => state.meshtastic.ready);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
<SwitchVerticalIcon
|
||||
className={`h-6 w-6 ${!ready ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
}
|
||||
circle
|
||||
/>
|
||||
);
|
||||
};
|
||||
23
src/components/menu/buttons/MobileNavToggle.tsx
Normal file
23
src/components/menu/buttons/MobileNavToggle.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { openMobileNav } from '@core/slices/appSlice';
|
||||
import { MenuIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { useAppDispatch } from '../../../hooks/redux';
|
||||
|
||||
export const MobileNavToggle = (): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
icon={<MenuIcon className="w-5 h-5" />}
|
||||
onClick={(): void => {
|
||||
dispatch(openMobileNav());
|
||||
}}
|
||||
circle
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
src/components/menu/buttons/ThemeToggle.tsx
Normal file
27
src/components/menu/buttons/ThemeToggle.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { setDarkModeEnabled } from '@core/slices/appSlice';
|
||||
import { MoonIcon, SunIcon } from '@heroicons/react/outline';
|
||||
|
||||
export const ThemeToggle = (): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
darkMode ? (
|
||||
<SunIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<MoonIcon className="w-5 h-5" />
|
||||
)
|
||||
}
|
||||
circle
|
||||
onClick={(): void => {
|
||||
dispatch(setDarkModeEnabled(!darkMode));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
31
src/components/nodes/Node.tsx
Normal file
31
src/components/nodes/Node.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
||||
|
||||
export interface NodeProps {
|
||||
node: Protobuf.NodeInfo;
|
||||
}
|
||||
|
||||
export const Node = ({
|
||||
node,
|
||||
...props
|
||||
}: NodeProps & DefaultDivProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className="flex space-x-4 items-center w-full rounded-md dark:bg-primaryDark shadow-md border dark:border-gray-600 p-2 mt-6 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
<Avatar
|
||||
size={30}
|
||||
name={node.user?.longName ?? 'UNK'}
|
||||
variant="beam"
|
||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
|
||||
/>
|
||||
<div>{node.user?.longName}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/templates/PrimaryTemplate.tsx
Normal file
45
src/components/templates/PrimaryTemplate.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface PrimaryTemplateProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
tagline: string;
|
||||
button?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
}
|
||||
|
||||
export const PrimaryTemplate = ({
|
||||
children,
|
||||
title,
|
||||
tagline,
|
||||
button,
|
||||
footer,
|
||||
}: PrimaryTemplateProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex flex-col flex-auto min-w-0">
|
||||
<div className="flex p-6 bg-white border-b md:flex-row flex-0 md:items-center md:justify-between md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
|
||||
{button && <div className="pr-2 m-auto md:hidden">{button}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center font-medium">
|
||||
<div>
|
||||
<a className="whitespace-nowrap text-primary">{tagline}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h2 className="text-3xl font-extrabold leading-7 tracking-tight truncate md:text-4xl md:leading-10 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-auto flex-grow p-6 md:p-10">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="flex p-6 bg-white border-t md:flex-row flex-0 md:items-center md:justify-between md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
|
||||
{button && <div className="pr-2 m-auto md:hidden">{button}</div>}
|
||||
<div className="flex-1 min-w-0">{footer}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
src/core/router.ts
Normal file
8
src/core/router.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createRouter, defineRoute } from 'type-route';
|
||||
|
||||
export const { RouteProvider, useRoute, routes } = createRouter({
|
||||
messages: defineRoute('/'),
|
||||
nodes: defineRoute('/nodes'),
|
||||
settings: defineRoute('/settings'),
|
||||
about: defineRoute('/about'),
|
||||
});
|
||||
45
src/core/slices/appSlice.ts
Normal file
45
src/core/slices/appSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export type currentPageName = 'messages' | 'settings';
|
||||
|
||||
interface AppState {
|
||||
mobileNavOpen: boolean;
|
||||
darkMode: boolean;
|
||||
currentPage: currentPageName;
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
mobileNavOpen: false,
|
||||
darkMode: localStorage.getItem('darkMode') === 'true' ?? false,
|
||||
currentPage: 'messages',
|
||||
};
|
||||
|
||||
export const appSlice = createSlice({
|
||||
name: 'app',
|
||||
initialState,
|
||||
reducers: {
|
||||
openMobileNav(state) {
|
||||
state.mobileNavOpen = true;
|
||||
},
|
||||
closeMobileNav(state) {
|
||||
state.mobileNavOpen = false;
|
||||
},
|
||||
setDarkModeEnabled(state, action: PayloadAction<boolean>) {
|
||||
localStorage.setItem('darkMode', String(action.payload));
|
||||
state.darkMode = action.payload;
|
||||
},
|
||||
setCurrentPage(state, action: PayloadAction<currentPageName>) {
|
||||
state.currentPage = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
openMobileNav,
|
||||
closeMobileNav,
|
||||
setDarkModeEnabled,
|
||||
setCurrentPage,
|
||||
} = appSlice.actions;
|
||||
|
||||
export default appSlice.reducer;
|
||||
@@ -2,40 +2,42 @@ import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { connection } from '../connection';
|
||||
|
||||
export interface MessageWithAck {
|
||||
message: Types.TextPacket;
|
||||
ack: boolean;
|
||||
isSender: boolean;
|
||||
received: Date;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
deviceStatus: Types.DeviceStatusEnum;
|
||||
lastMeshInterraction: number;
|
||||
ready: boolean;
|
||||
fromRaioPackets: Protobuf.FromRadio[];
|
||||
meshPackets: Protobuf.MeshPacket[];
|
||||
myNodeInfo: Protobuf.MyNodeInfo;
|
||||
radioConfig: Protobuf.RadioConfig[];
|
||||
routingPackets: Types.RoutingPacket[];
|
||||
positionPackets: Types.PositionPacket[];
|
||||
textPackets: Types.TextPacket[];
|
||||
logRecords: Protobuf.LogRecord[];
|
||||
//
|
||||
nodes: Protobuf.NodeInfo[];
|
||||
channels: Protobuf.Channel[];
|
||||
preferences: Protobuf.RadioConfig_UserPreferences;
|
||||
messages: MessageWithAck[];
|
||||
hostOverrideEnabled: boolean;
|
||||
hostOverride: string;
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
deviceStatus: Types.DeviceStatusEnum.DEVICE_DISCONNECTED,
|
||||
lastMeshInterraction: 0,
|
||||
ready: false,
|
||||
fromRaioPackets: [],
|
||||
meshPackets: [],
|
||||
myNodeInfo: Protobuf.MyNodeInfo.create(),
|
||||
radioConfig: [],
|
||||
routingPackets: [],
|
||||
positionPackets: [],
|
||||
textPackets: [],
|
||||
logRecords: [],
|
||||
//
|
||||
nodes: [],
|
||||
channels: [],
|
||||
preferences: Protobuf.RadioConfig_UserPreferences.create(),
|
||||
messages: [],
|
||||
hostOverrideEnabled:
|
||||
localStorage.getItem('hostOverrideEnabled') === 'true' ?? false,
|
||||
hostOverride: localStorage.getItem('hostOverride') ?? '',
|
||||
};
|
||||
|
||||
export const meshtasticSlice = createSlice({
|
||||
@@ -51,31 +53,12 @@ export const meshtasticSlice = createSlice({
|
||||
setReady: (state, action: PayloadAction<boolean>) => {
|
||||
state.ready = action.payload;
|
||||
},
|
||||
addFromRadioPacket: (state, action: PayloadAction<Protobuf.FromRadio>) => {
|
||||
state.fromRaioPackets.push(action.payload);
|
||||
},
|
||||
addMeshPacket: (state, action: PayloadAction<Protobuf.MeshPacket>) => {
|
||||
state.meshPackets.push(action.payload);
|
||||
},
|
||||
setMyNodeInfo: (state, action: PayloadAction<Protobuf.MyNodeInfo>) => {
|
||||
state.myNodeInfo = action.payload;
|
||||
},
|
||||
addRadioConfig: (state, action: PayloadAction<Protobuf.RadioConfig>) => {
|
||||
state.radioConfig.push(action.payload);
|
||||
},
|
||||
addRoutingPacket: (state, action: PayloadAction<Types.RoutingPacket>) => {
|
||||
state.routingPackets.push(action.payload);
|
||||
},
|
||||
addPositionPacket: (state, action: PayloadAction<Types.PositionPacket>) => {
|
||||
state.positionPackets.push(action.payload);
|
||||
},
|
||||
addTextPacket: (state, action: PayloadAction<Types.TextPacket>) => {
|
||||
state.textPackets.push(action.payload);
|
||||
},
|
||||
addLogRecord: (state, action: PayloadAction<Protobuf.LogRecord>) => {
|
||||
state.logRecords.push(action.payload);
|
||||
},
|
||||
//
|
||||
addNode: (state, action: PayloadAction<Protobuf.NodeInfo>) => {
|
||||
if (
|
||||
state.nodes.findIndex((node) => node.num === action.payload.num) !== -1
|
||||
@@ -109,6 +92,30 @@ export const meshtasticSlice = createSlice({
|
||||
) => {
|
||||
state.preferences = action.payload;
|
||||
},
|
||||
addMessage: (state, action: PayloadAction<MessageWithAck>) => {
|
||||
state.messages.push(action.payload);
|
||||
},
|
||||
ackMessage: (state, messageId: PayloadAction<number>) => {
|
||||
state.messages.map((message) => {
|
||||
if (message.message.packet.id === messageId.payload) {
|
||||
message.ack = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
setHostOverrideEnabled: (state, action: PayloadAction<boolean>) => {
|
||||
state.hostOverrideEnabled = action.payload;
|
||||
localStorage.setItem('hostOverrideEnabled', String(action.payload));
|
||||
if (state.hostOverrideEnabled !== action.payload) {
|
||||
connection.disconnect();
|
||||
}
|
||||
},
|
||||
setHostOverride: (state, action: PayloadAction<string>) => {
|
||||
state.hostOverride = action.payload;
|
||||
localStorage.setItem('hostOverride', action.payload);
|
||||
if (state.hostOverride !== action.payload) {
|
||||
connection.disconnect();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,17 +123,15 @@ export const {
|
||||
setDeviceStatus,
|
||||
setLastMeshInterraction,
|
||||
setReady,
|
||||
addFromRadioPacket,
|
||||
addMeshPacket,
|
||||
setMyNodeInfo,
|
||||
addRadioConfig,
|
||||
addRoutingPacket,
|
||||
addPositionPacket,
|
||||
addTextPacket,
|
||||
addLogRecord,
|
||||
addNode,
|
||||
addChannel,
|
||||
setPreferences,
|
||||
addMessage,
|
||||
ackMessage,
|
||||
setHostOverrideEnabled,
|
||||
setHostOverride,
|
||||
} = meshtasticSlice.actions;
|
||||
|
||||
export default meshtasticSlice.reducer;
|
||||
@@ -2,17 +2,19 @@ import i18n from 'i18next';
|
||||
import detector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './translations/en.json';
|
||||
import { en } from '../translations/en';
|
||||
import { jp } from '../translations/jp';
|
||||
import { pt } from '../translations/pt';
|
||||
|
||||
i18n
|
||||
void i18n
|
||||
.use(detector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
en: { translation: en },
|
||||
jp: { translation: jp },
|
||||
pt: { translation: pt },
|
||||
},
|
||||
});
|
||||
|
||||
21
src/hooks/breakpoint.ts
Normal file
21
src/hooks/breakpoint.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import useBreakpointHook from 'use-breakpoint';
|
||||
|
||||
const BREAKPOINTS = {
|
||||
sm: 640,
|
||||
// => @media (min-width: 640px) { ... }
|
||||
|
||||
md: 768,
|
||||
// => @media (min-width: 768px) { ... }
|
||||
|
||||
lg: 1024,
|
||||
// => @media (min-width: 1024px) { ... }
|
||||
|
||||
xl: 1280,
|
||||
// => @media (min-width: 1280px) { ... }
|
||||
|
||||
'2xl': 1536,
|
||||
// => @media (min-width: 1536px) { ... }
|
||||
};
|
||||
export const useBreakpoint = () => useBreakpointHook(BREAKPOINTS);
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import type { AppDispatch, RootState } from '../store';
|
||||
import type { AppDispatch, RootState } from '@core/store';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import './index.css';
|
||||
import './translation';
|
||||
import '@app/index.css';
|
||||
import '@core/translation';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { RouteProvider } from '@core/router';
|
||||
import { store } from '@core/store';
|
||||
|
||||
import App from './App';
|
||||
import { store } from './store';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
<RouteProvider>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</RouteProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
|
||||
11
src/pages/About.tsx
Normal file
11
src/pages/About.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
|
||||
export const About = (): JSX.Element => {
|
||||
return (
|
||||
<PrimaryTemplate title="meshtastic-web" tagline="About">
|
||||
<p>Content</p>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
55
src/pages/Messages.tsx
Normal file
55
src/pages/Messages.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Message } from '@components/chat/Message';
|
||||
import { MessageBar } from '@components/chat/MessageBar';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { HashtagIcon, MapIcon, UsersIcon } from '@heroicons/react/outline';
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { useAppSelector } from '../hooks/redux';
|
||||
|
||||
export const Messages = (): JSX.Element => {
|
||||
const messages = useAppSelector((state) => state.meshtastic.messages);
|
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||
const channels = useAppSelector((state) => state.meshtastic.channels);
|
||||
|
||||
const channelName = (): string => {
|
||||
const name =
|
||||
channels.find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY)
|
||||
?.settings?.name ?? 'Unknown';
|
||||
|
||||
return name.length ? name : 'Default';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex justify-between w-full px-2 border-b dark:border-gray-600 dark:text-gray-300">
|
||||
<div className="flex my-auto text-sm">
|
||||
<HashtagIcon className="w-4 h-4 my-auto" />
|
||||
{channelName()}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button icon={<MapIcon className="w-5 h-5" />} circle />
|
||||
|
||||
<Button icon={<UsersIcon className="w-5 h-5" />} circle />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow p-6 space-y-2 overflow-y-auto bg-white border-b md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
|
||||
{messages.map((message, index) => (
|
||||
<Message
|
||||
key={index}
|
||||
isSender={message.isSender}
|
||||
message={message.message.data}
|
||||
ack={message.ack}
|
||||
rxTime={new Date()}
|
||||
senderName={
|
||||
nodes.find((node) => node.num === message.message.packet.from)
|
||||
?.user?.longName ?? 'UNK'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<MessageBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
src/pages/Nodes/Index.tsx
Normal file
92
src/pages/Nodes/Index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
|
||||
import { useBreakpoint } from '@app/hooks/breakpoint';
|
||||
import { useAppSelector } from '@app/hooks/redux';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { Drawer } from '@components/generic/Drawer';
|
||||
import { SidebarItem } from '@components/generic/SidebarItem';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { XCircleIcon } from '@heroicons/react/outline';
|
||||
|
||||
import { Node } from './Node';
|
||||
|
||||
export const Nodes = (): JSX.Element => {
|
||||
const [navOpen, setNavOpen] = React.useState(false);
|
||||
|
||||
const { breakpoint } = useBreakpoint();
|
||||
|
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||
|
||||
return (
|
||||
<Tab.Group>
|
||||
<div className="relative flex w-full dark:text-white">
|
||||
<Drawer
|
||||
open={breakpoint === 'sm' ? navOpen : true}
|
||||
permenant={breakpoint !== 'sm'}
|
||||
onClose={(): void => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
>
|
||||
<Tab.List className="flex flex-col border-b divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600">
|
||||
<div className="flex items-center justify-between m-8 mr-6 md:my-10">
|
||||
<div className="text-4xl font-extrabold leading-none tracking-tight">
|
||||
Nodes
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
icon={<XCircleIcon className="w-5 h-5" />}
|
||||
circle
|
||||
onClick={(): void => {
|
||||
setNavOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nodes.map((node) => (
|
||||
<Tab
|
||||
onClick={(): void => {
|
||||
setNavOpen(false);
|
||||
}}
|
||||
key={node.num}
|
||||
>
|
||||
{({ selected }): JSX.Element => (
|
||||
<SidebarItem
|
||||
title={node.user?.longName ?? node.num.toString()}
|
||||
description="Node info"
|
||||
selected={selected}
|
||||
icon={
|
||||
<Avatar
|
||||
size={30}
|
||||
name={node.user?.longName ?? node.num.toString()}
|
||||
variant="beam"
|
||||
colors={[
|
||||
'#213435',
|
||||
'#46685B',
|
||||
'#648A64',
|
||||
'#A6B985',
|
||||
'#E1E3AC',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
</Drawer>
|
||||
<div className="w-full">
|
||||
<Tab.Panels>
|
||||
{nodes.map((node) => (
|
||||
<Tab.Panel key={node.num}>
|
||||
<Node navOpen={navOpen} setNavOpen={setNavOpen} node={node} />
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Group>
|
||||
);
|
||||
};
|
||||
32
src/pages/Nodes/Node.tsx
Normal file
32
src/pages/Nodes/Node.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
import { MenuIcon } from '@heroicons/react/outline';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface NodeProps {
|
||||
navOpen: boolean;
|
||||
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
node: Protobuf.NodeInfo;
|
||||
}
|
||||
|
||||
export const Node = ({ navOpen, setNavOpen, node }: NodeProps): JSX.Element => {
|
||||
return (
|
||||
<PrimaryTemplate
|
||||
title={node.user?.longName ?? node.num.toString()}
|
||||
tagline="Node"
|
||||
button={
|
||||
<Button
|
||||
icon={<MenuIcon className="w-5 h-5" />}
|
||||
onClick={(): void => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
circle
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="w-full max-w-3xl space-y-2 md:max-w-xl">Content</div>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
69
src/pages/settings/Device.tsx
Normal file
69
src/pages/settings/Device.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { connection } from '@app/core/connection';
|
||||
import { useAppSelector } from '@app/hooks/redux';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { Input } from '@components/generic/Input';
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
import { MenuIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface DeviceProps {
|
||||
navOpen: boolean;
|
||||
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const Device = ({ navOpen, setNavOpen }: DeviceProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const radioConfig = useAppSelector((state) => state.meshtastic.preferences);
|
||||
|
||||
const { register, handleSubmit, formState } =
|
||||
useForm<Protobuf.RadioConfig_UserPreferences>({
|
||||
defaultValues: radioConfig,
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
void connection.setPreferences(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<PrimaryTemplate
|
||||
title="Device"
|
||||
tagline="Settings"
|
||||
button={
|
||||
<Button
|
||||
icon={<MenuIcon className="w-5 h-5" />}
|
||||
onClick={(): void => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
circle
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
className="px-10 ml-auto"
|
||||
icon={<SaveIcon className="w-5 h-5" />}
|
||||
disabled={!formState.isDirty}
|
||||
active
|
||||
border
|
||||
>
|
||||
{t('strings.save_changes')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="w-full max-w-3xl space-y-2 md:max-w-xl">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Input label={t('strings.wifi_ssid')} {...register('wifiSsid')} />
|
||||
<Input
|
||||
type="password"
|
||||
label={t('strings.wifi_psk')}
|
||||
{...register('wifiPassword')}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
101
src/pages/settings/Index.tsx
Normal file
101
src/pages/settings/Index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useBreakpoint } from '@app/hooks/breakpoint';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { Drawer } from '@components/generic/Drawer';
|
||||
import { SidebarItem } from '@components/generic/SidebarItem';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import {
|
||||
CollectionIcon,
|
||||
DeviceMobileIcon,
|
||||
WifiIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
|
||||
import { Device } from './Device';
|
||||
import { Interface } from './Interface';
|
||||
import { Radio } from './Radio';
|
||||
|
||||
export const Settings = (): JSX.Element => {
|
||||
const [navOpen, setNavOpen] = React.useState(false);
|
||||
|
||||
const { breakpoint } = useBreakpoint();
|
||||
|
||||
return (
|
||||
<Tab.Group>
|
||||
<div className="relative flex w-full dark:text-white">
|
||||
<Drawer
|
||||
open={breakpoint === 'sm' ? navOpen : true}
|
||||
permenant={breakpoint !== 'sm'}
|
||||
onClose={(): void => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
>
|
||||
<Tab.List className="flex flex-col border-b divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600">
|
||||
<div className="flex items-center justify-between m-8 mr-6 md:my-10">
|
||||
<div className="text-4xl font-extrabold leading-none tracking-tight">
|
||||
Settings
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
icon={<XCircleIcon className="w-5 h-5" />}
|
||||
circle
|
||||
onClick={(): void => {
|
||||
setNavOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Tab
|
||||
onClick={(): void => {
|
||||
setNavOpen(false);
|
||||
}}
|
||||
>
|
||||
{({ selected }): JSX.Element => (
|
||||
<SidebarItem
|
||||
title="Device"
|
||||
description="Device settings, such as device name and wifi settings"
|
||||
selected={selected}
|
||||
icon={<DeviceMobileIcon className="flex-shrink-0 w-6 h-6" />}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>
|
||||
{({ selected }): JSX.Element => (
|
||||
<SidebarItem
|
||||
title="Radio"
|
||||
description="Adjust radio power and frequency settings"
|
||||
selected={selected}
|
||||
icon={<WifiIcon className="flex-shrink-0 w-6 h-6" />}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>
|
||||
{({ selected }): JSX.Element => (
|
||||
<SidebarItem
|
||||
title="Interface"
|
||||
description="Change language and other UI settings"
|
||||
selected={selected}
|
||||
icon={<CollectionIcon className="flex-shrink-0 w-6 h-6" />}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
</Drawer>
|
||||
<div className="flex w-full">
|
||||
<Tab.Panels className="flex w-full">
|
||||
<Tab.Panel className="flex w-full">
|
||||
<Device navOpen={navOpen} setNavOpen={setNavOpen} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="flex w-full">
|
||||
<Radio navOpen={navOpen} setNavOpen={setNavOpen} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="flex w-full">
|
||||
<Interface navOpen={navOpen} setNavOpen={setNavOpen} />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Group>
|
||||
);
|
||||
};
|
||||
76
src/pages/settings/Interface.tsx
Normal file
76
src/pages/settings/Interface.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Jp, Pt, Us } from 'react-flags-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Select } from '@app/components/generic/Select';
|
||||
import i18n from '@app/core/translation';
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
import { MenuIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
|
||||
export interface InterfaceProps {
|
||||
navOpen: boolean;
|
||||
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const Interface = ({
|
||||
navOpen,
|
||||
setNavOpen,
|
||||
}: InterfaceProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PrimaryTemplate
|
||||
title="Interface"
|
||||
tagline="Settings"
|
||||
button={
|
||||
<Button
|
||||
icon={<MenuIcon className="w-5 h-5" />}
|
||||
onClick={(): void => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
circle
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
className="px-10 ml-auto"
|
||||
icon={<SaveIcon className="w-5 h-5" />}
|
||||
active
|
||||
border
|
||||
>
|
||||
{t('strings.save_changes')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="w-full max-w-3xl space-y-2 md:max-w-xl">
|
||||
<Select
|
||||
label="Language"
|
||||
value={i18n.language}
|
||||
onChange={(value): void => {
|
||||
void i18n.changeLanguage(value);
|
||||
}}
|
||||
id="aaa"
|
||||
options={[
|
||||
{
|
||||
name: 'English',
|
||||
value: 'en',
|
||||
icon: <Us className="w-6" />,
|
||||
},
|
||||
{
|
||||
name: 'Português',
|
||||
value: 'pt',
|
||||
icon: <Pt className="w-6" />,
|
||||
},
|
||||
{
|
||||
name: 'Japanese',
|
||||
value: 'jp',
|
||||
icon: <Jp className="w-6" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
47
src/pages/settings/Radio.tsx
Normal file
47
src/pages/settings/Radio.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@components/generic/Button';
|
||||
import { Input } from '@components/generic/Input';
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
import { MenuIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
|
||||
export interface RadioProps {
|
||||
navOpen: boolean;
|
||||
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const Radio = ({ navOpen, setNavOpen }: RadioProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PrimaryTemplate
|
||||
title="Radio"
|
||||
tagline="Settings"
|
||||
button={
|
||||
<Button
|
||||
icon={<MenuIcon className="w-5 h-5" />}
|
||||
onClick={(): void => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
circle
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
className="px-10 ml-auto"
|
||||
icon={<SaveIcon className="w-5 h-5" />}
|
||||
active
|
||||
border
|
||||
>
|
||||
{t('strings.save_changes')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="w-full max-w-3xl space-y-2 md:max-w-xl">
|
||||
<Input label="test" />
|
||||
</div>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
interface AppState {
|
||||
sidebarOpen: boolean;
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
sidebarOpen: true,
|
||||
darkMode: false,
|
||||
};
|
||||
|
||||
export const appSlice = createSlice({
|
||||
name: 'app',
|
||||
initialState,
|
||||
reducers: {
|
||||
openSidebar(state) {
|
||||
state.sidebarOpen = true;
|
||||
},
|
||||
closeSidebar(state) {
|
||||
state.sidebarOpen = false;
|
||||
},
|
||||
toggleSidebar(state) {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { openSidebar, closeSidebar, toggleSidebar } = appSlice.actions;
|
||||
|
||||
export default appSlice.reducer;
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"errors": {},
|
||||
"placeholder": {
|
||||
"message": "Enter Message",
|
||||
"no_messages": "No messages yet",
|
||||
"no_nodes": "No nodes found"
|
||||
},
|
||||
"strings": {
|
||||
"nodes": "Nodes",
|
||||
"color_scheme": "Color scheme",
|
||||
"language": "Language",
|
||||
"device_region": "Device region",
|
||||
"wifi_ssid": "WiFi SSID",
|
||||
"wifi_psk": "WiFi PSK",
|
||||
"save_changes": "Save changes"
|
||||
},
|
||||
"settings": {
|
||||
"ui": "UI Settings",
|
||||
"device": "Device Settings",
|
||||
"channel": "Channels"
|
||||
}
|
||||
}
|
||||
22
src/translations/en.ts
Normal file
22
src/translations/en.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const en = {
|
||||
errors: {},
|
||||
placeholder: {
|
||||
message: 'Enter Message',
|
||||
no_messages: 'No messages yet',
|
||||
no_nodes: 'No nodes found',
|
||||
},
|
||||
strings: {
|
||||
nodes: 'Nodes',
|
||||
color_scheme: 'Color scheme',
|
||||
language: 'Language',
|
||||
device_region: 'Device region',
|
||||
wifi_ssid: 'WiFi SSID',
|
||||
wifi_psk: 'WiFi PSK',
|
||||
save_changes: 'Save changes',
|
||||
},
|
||||
settings: {
|
||||
ui: 'UI Settings',
|
||||
device: 'Device Settings',
|
||||
channel: 'Channels',
|
||||
},
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"errors": {},
|
||||
"placeholder": {
|
||||
"message": "メッセージを入力してください",
|
||||
"no_messages": "まだメッセージはありません",
|
||||
"no_nodes": "ノードが見つかりません"
|
||||
},
|
||||
"strings": {
|
||||
"nodes": "ノード",
|
||||
"color_scheme": "カラースキーム",
|
||||
"language": "言語",
|
||||
"device_region": "デバイスリージョン",
|
||||
"wifi_ssid": "WiFi名",
|
||||
"wifi_psk": "WiFiパスワード",
|
||||
"save_changes": "変更内容を保存"
|
||||
},
|
||||
"settings": {
|
||||
"ui": "UI設定",
|
||||
"device": "デバイスの設定",
|
||||
"channel": "#################"
|
||||
}
|
||||
}
|
||||
22
src/translations/jp.ts
Normal file
22
src/translations/jp.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const jp = {
|
||||
errors: {},
|
||||
placeholder: {
|
||||
message: 'メッセージを入力してください',
|
||||
no_messages: 'まだメッセージはありません',
|
||||
no_nodes: 'ノードが見つかりません',
|
||||
},
|
||||
strings: {
|
||||
nodes: 'ノード',
|
||||
color_scheme: 'カラースキーム',
|
||||
language: '言語',
|
||||
device_region: 'デバイスリージョン',
|
||||
wifi_ssid: 'WiFi名',
|
||||
wifi_psk: 'WiFiパスワード',
|
||||
save_changes: '変更内容を保存',
|
||||
},
|
||||
settings: {
|
||||
ui: 'UI設定',
|
||||
device: 'デバイスの設定',
|
||||
channel: '#################',
|
||||
},
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"errors": {},
|
||||
"placeholder": {
|
||||
"message": "Entre mensagem",
|
||||
"no_messages": "Não a mensagens ainda",
|
||||
"no_nodes": "Nenhum nó foi encontrado"
|
||||
},
|
||||
"strings": {
|
||||
"nodes": "Nós",
|
||||
"color_scheme": "Esquema de cores",
|
||||
"language": "Idioma",
|
||||
"device_region": "Região do dispositivo",
|
||||
"wifi_ssid": "Nome do WiFi",
|
||||
"wifi_psk": "Senha do WiFi",
|
||||
"save_changes": "Salvar alterações"
|
||||
},
|
||||
"settings": {
|
||||
"ui": "Configurações da Interface",
|
||||
"device": "Configurações do dispositivo",
|
||||
"channel": "Canais"
|
||||
}
|
||||
}
|
||||
22
src/translations/pt.ts
Normal file
22
src/translations/pt.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const pt = {
|
||||
errors: {},
|
||||
placeholder: {
|
||||
message: 'Entre mensagem',
|
||||
no_messages: 'Não a mensagens ainda',
|
||||
no_nodes: 'Nenhum nó foi encontrado',
|
||||
},
|
||||
strings: {
|
||||
nodes: 'Nós',
|
||||
color_scheme: 'Esquema de cores',
|
||||
language: 'Idioma',
|
||||
device_region: 'Região do dispositivo',
|
||||
wifi_ssid: 'Nome do WiFi',
|
||||
wifi_psk: 'Senha do WiFi',
|
||||
save_changes: 'Salvar alterações',
|
||||
},
|
||||
settings: {
|
||||
ui: 'Configurações da Interface',
|
||||
device: 'Configurações do dispositivo',
|
||||
channel: 'Canais',
|
||||
},
|
||||
};
|
||||
@@ -1,12 +1,19 @@
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
purge: ['./public/**/*.html', './src/**/*.tsx'],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
darkMode: 'class', // or 'media' or 'class'
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
fontFamily: {
|
||||
sans: 'Roboto',
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#67ea94',
|
||||
primaryDark: '#1E293B',
|
||||
secondaryDark: '#0F172A',
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -12,10 +12,17 @@
|
||||
// Add this line to get types for streaming imports (packageOptions.source="remote"):
|
||||
// "*": [".snowpack/types/*"]
|
||||
// More info: https://www.snowpack.dev/guides/streaming-imports
|
||||
"@app/*": ["./src/*"],
|
||||
"@pages/*": ["./src/pages/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@core/*": ["./src/core/*"],
|
||||
"@static/*": ["./src/static/*"]
|
||||
},
|
||||
/* noEmit - Snowpack builds (emits) files, not tsc. */
|
||||
"noEmit": true,
|
||||
/* Additional Options */
|
||||
"importHelpers": true,
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user