mirror of
https://github.com/aljazceru/meshcore-web.git
synced 2025-12-17 08:14:19 +01:00
start implementing client based on meshtxt
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/.idea
|
||||
/node_modules
|
||||
2428
package-lock.json
generated
2428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -2,10 +2,25 @@
|
||||
"name": "meshcore-web",
|
||||
"version": "0.0.1",
|
||||
"description": "A web based MeshCore client developed by Liam Cottle",
|
||||
"main": "index.js",
|
||||
"main": "src/main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"author": "Liam Cottle <liam@liamcottle.com>",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@liamcottle/meshcore.js": "file:../meshcore.js",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"click-outside-vue3": "^4.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
10
postcss.config.js
Normal file
10
postcss.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {
|
||||
|
||||
},
|
||||
autoprefixer: {
|
||||
|
||||
},
|
||||
},
|
||||
}
|
||||
12
src/components/App.vue
Normal file
12
src/components/App.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<RouterView/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
}
|
||||
</script>
|
||||
43
src/components/AppBar.vue
Normal file
43
src/components/AppBar.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex bg-white py-2 border-b h-16">
|
||||
|
||||
<!-- back button -->
|
||||
<div class="my-auto px-1">
|
||||
<IconButton @click="$router.back()" class="bg-transparent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<!-- optional: leading -->
|
||||
<div class="my-auto">
|
||||
<slot name="leading"/>
|
||||
</div>
|
||||
|
||||
<!-- title and subtitle -->
|
||||
<div class="my-auto mr-auto overflow-hidden">
|
||||
<div class="font-bold">{{ title }}</div>
|
||||
<div v-if="subtitle != null" class="text-sm truncate">{{ subtitle }}</div>
|
||||
</div>
|
||||
|
||||
<!-- optional: trailing -->
|
||||
<div class="flex my-auto mx-2">
|
||||
<slot name="trailing"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import IconButton from "./IconButton.vue";
|
||||
|
||||
export default {
|
||||
name: 'AppBar',
|
||||
components: {IconButton},
|
||||
props: {
|
||||
title: String,
|
||||
subtitle: String | null,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
92
src/components/DropDownMenu.vue
Normal file
92
src/components/DropDownMenu.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div v-click-outside="{ handler: onClickOutsideMenu, capture: true }" class="cursor-default relative inline-block text-left">
|
||||
|
||||
<!-- menu button -->
|
||||
<div ref="dropdown-button" @click.stop="toggleMenu">
|
||||
<slot name="button"/>
|
||||
</div>
|
||||
|
||||
<!-- drop down menu -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none" :class="[ dropdownClass ]">
|
||||
<slot name="items"/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DropDownMenu',
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
dropdownClass: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleMenu() {
|
||||
if(this.isShowingMenu){
|
||||
this.hideMenu();
|
||||
} else {
|
||||
this.showMenu();
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
this.adjustDropdownPosition();
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
onClickOutsideMenu(event) {
|
||||
if(this.isShowingMenu){
|
||||
event.preventDefault();
|
||||
this.hideMenu();
|
||||
}
|
||||
},
|
||||
adjustDropdownPosition() {
|
||||
this.$nextTick(() => {
|
||||
|
||||
// find button and dropdown
|
||||
const button = this.$refs["dropdown-button"];
|
||||
const dropdown = button.nextElementSibling;
|
||||
|
||||
// do nothing if not found
|
||||
if(!button || !dropdown){
|
||||
return;
|
||||
}
|
||||
|
||||
// get bounding box of button and dropdown
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const dropdownRect = dropdown.getBoundingClientRect();
|
||||
|
||||
// calculate how much space is under and above the button
|
||||
const spaceBelowButton = window.innerHeight - buttonRect.bottom;
|
||||
const spaceAboveButton = buttonRect.top;
|
||||
|
||||
// calculate if there is enough space available to show dropdown
|
||||
const hasEnoughSpaceAboveButton = spaceAboveButton > dropdownRect.height;
|
||||
const hasEnoughSpaceBelowButton = spaceBelowButton > dropdownRect.height;
|
||||
|
||||
// show dropdown above button
|
||||
if(hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton){
|
||||
this.dropdownClass = "bottom-0 mb-12";
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise fallback to showing dropdown below button
|
||||
this.dropdownClass = "top-0 mt-12";
|
||||
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
11
src/components/DropDownMenuItem.vue
Normal file
11
src/components/DropDownMenuItem.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-500 hover:bg-gray-100">
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DropDownMenuItem',
|
||||
}
|
||||
</script>
|
||||
63
src/components/Header.vue
Normal file
63
src/components/Header.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex bg-white p-2 border-b h-16">
|
||||
<div class="my-auto mr-auto overflow-hidden">
|
||||
<div class="font-bold">MeshCore</div>
|
||||
<div class="text-sm truncate">
|
||||
|
||||
<!-- connected or configured -->
|
||||
<span v-if="GlobalState.connection != null">
|
||||
<span v-if="GlobalState.selfInfo">{{ GlobalState.selfInfo.name }}</span>
|
||||
<span v-else>Connecting...</span>
|
||||
</span>
|
||||
|
||||
<!-- disconnected -->
|
||||
<span v-else>
|
||||
Built by <a href="https://liamcottle.com" target="_blank" class="text-blue-600 hover:underline">Liam Cottle</a>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto flex font-semibold">
|
||||
|
||||
<!-- connect button -->
|
||||
<RouterLink v-if="GlobalState.connection == null" :to="{ name: 'connect' }">
|
||||
<div class="bg-blue-500 text-white px-2 py-1 rounded shadow hover:bg-blue-400">
|
||||
Connect
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- disconnect button -->
|
||||
<div v-else class="space-x-1">
|
||||
<button @click="sendFloodAdvert" type="button" class="bg-gray-500 text-white px-2 py-1 p-1 rounded shadow hover:bg-gray-400">
|
||||
Advert
|
||||
</button>
|
||||
<button @click="disconnect" type="button" class="bg-gray-500 text-white px-2 py-1 p-1 rounded shadow hover:bg-gray-400">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GlobalState from "../js/GlobalState.js";
|
||||
import Connection from "../js/Connection.js";
|
||||
|
||||
export default {
|
||||
name: 'Header',
|
||||
methods: {
|
||||
async sendFloodAdvert() {
|
||||
await GlobalState.connection.sendFloodAdvert();
|
||||
},
|
||||
async disconnect() {
|
||||
await Connection.disconnect();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
GlobalState() {
|
||||
return GlobalState;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
11
src/components/IconButton.vue
Normal file
11
src/components/IconButton.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<button type="button" class="p-2 rounded-full hover:bg-gray-200">
|
||||
<slot/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'IconButton',
|
||||
}
|
||||
</script>
|
||||
72
src/components/connect/ConnectButtons.vue
Normal file
72
src/components/connect/ConnectButtons.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
|
||||
<!-- info -->
|
||||
<div class="flex flex-col mx-auto my-auto text-gray-700 text-center">
|
||||
<div class="mb-2 mx-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="w-10">
|
||||
<rect width="256" height="256" fill="none"/>
|
||||
<circle cx="136" cy="64" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<line x1="8" y1="128" x2="200" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<polygon points="200 96 200 160 248 128 200 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<rect x="112" y="168" width="48" height="48" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<path d="M112,64H72a8,8,0,0,0-8,8V184a8,8,0,0,0,8,8h40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">Not Connected</div>
|
||||
<div>Connect a MeshCore device to continue</div>
|
||||
</div>
|
||||
|
||||
<!-- bluetooth -->
|
||||
<button @click="connectViaBluetooth" type="button" class="w-full flex cursor-pointer bg-white rounded shadow px-3 py-2 text-black space-x-2 font-semibold hover:bg-gray-100">
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="w-6">
|
||||
<rect width="256" height="256" fill="none"/>
|
||||
<polygon points="128 32 192 80 128 128 128 32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<polygon points="128 128 192 176 128 224 128 128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<line x1="64" y1="80" x2="128" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<line x1="64" y1="176" x2="128" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Connect via Bluetooth</span>
|
||||
</button>
|
||||
|
||||
<!-- serial -->
|
||||
<button @click="connectViaSerial" type="button" class="w-full flex cursor-pointer bg-white rounded shadow px-3 py-2 text-black space-x-2 font-semibold hover:bg-gray-100">
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="w-6">
|
||||
<rect width="256" height="256" fill="none"/>
|
||||
<circle cx="136" cy="64" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<line x1="8" y1="128" x2="200" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<polygon points="200 96 200 160 248 128 200 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<rect x="112" y="168" width="48" height="48" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
<path d="M112,64H72a8,8,0,0,0-8,8V184a8,8,0,0,0,8,8h40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Connect via Serial</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Connection from "../../js/Connection.js";
|
||||
|
||||
export default {
|
||||
name: 'ConnectButtons',
|
||||
methods: {
|
||||
async connectViaBluetooth() {
|
||||
await Connection.connectViaBluetooth();
|
||||
this.$router.push({
|
||||
name: "main",
|
||||
});
|
||||
},
|
||||
async connectViaSerial() {
|
||||
await Connection.connectViaSerial();
|
||||
this.$router.push({
|
||||
name: "main",
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
60
src/components/contacts/ContactListItem.vue
Normal file
60
src/components/contacts/ContactListItem.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex cursor-pointer p-2 bg-white hover:bg-gray-50">
|
||||
|
||||
<!-- name and info -->
|
||||
<div class="mr-auto">
|
||||
<div>{{ contact.advName }}</div>
|
||||
<div class="flex space-x-1 text-sm text-gray-500">
|
||||
|
||||
<span class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">
|
||||
<path d="M9 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" />
|
||||
<path fill-rule="evenodd" d="M9.68 5.26a.75.75 0 0 1 1.06 0 3.875 3.875 0 0 1 0 5.48.75.75 0 1 1-1.06-1.06 2.375 2.375 0 0 0 0-3.36.75.75 0 0 1 0-1.06Zm-3.36 0a.75.75 0 0 1 0 1.06 2.375 2.375 0 0 0 0 3.36.75.75 0 1 1-1.06 1.06 3.875 3.875 0 0 1 0-5.48.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M11.89 3.05a.75.75 0 0 1 1.06 0 7 7 0 0 1 0 9.9.75.75 0 1 1-1.06-1.06 5.5 5.5 0 0 0 0-7.78.75.75 0 0 1 0-1.06Zm-7.78 0a.75.75 0 0 1 0 1.06 5.5 5.5 0 0 0 0 7.78.75.75 0 1 1-1.06 1.06 7 7 0 0 1 0-9.9.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- last heard -->
|
||||
<span class="flex my-auto text-sm text-gray-500 space-x-1">
|
||||
{{ formatUnixSecondsAgo(contact.lastAdvert) }}
|
||||
</span>
|
||||
|
||||
<!-- hops away -->
|
||||
<span class="flex my-auto text-sm text-gray-500 space-x-1">
|
||||
<span v-if="contact.outPathLen === -1">• No Path (Flood)</span>
|
||||
<span v-else-if="contact.outPathLen === 0">• Direct</span>
|
||||
<span v-else-if="contact.outPathLen === 1">• 1 Hop</span>
|
||||
<span v-else>• {{ contact.outPathLen }} Hops</span>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GlobalState from "../../js/GlobalState.js";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import TimeUtils from "../../js/TimeUtils.js";
|
||||
|
||||
export default {
|
||||
name: 'ContactListItem',
|
||||
components: {
|
||||
IconButton,
|
||||
},
|
||||
props: {
|
||||
contact: Object,
|
||||
},
|
||||
methods: {
|
||||
formatUnixSecondsAgo(unixSeconds) {
|
||||
return TimeUtils.formatUnixSecondsAgo(unixSeconds);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
GlobalState() {
|
||||
return GlobalState;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
153
src/components/contacts/ContactsList.vue
Normal file
153
src/components/contacts/ContactsList.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full overflow-hidden">
|
||||
|
||||
<!-- search -->
|
||||
<div v-if="contacts.length > 0" class="flex bg-white border-b border-gray-300 divide-x">
|
||||
<div class="flex p-1 w-full">
|
||||
<input v-model="contactsSearchTerm" type="text" :placeholder="`Search ${contacts.length} Contacts...`" class="h-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
||||
</div>
|
||||
<div class="flex text-gray-500">
|
||||
<DropDownMenu class="mx-auto my-auto">
|
||||
<template v-slot:button>
|
||||
<IconButton class="mx-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
||||
<path d="M18.75 12.75h1.5a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5ZM12 6a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 12 6ZM12 18a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 12 18ZM3.75 6.75h1.5a.75.75 0 1 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5ZM5.25 18.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 0 1.5ZM3 12a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 3 12ZM9 3.75a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5ZM12.75 12a2.25 2.25 0 1 1 4.5 0 2.25 2.25 0 0 1-4.5 0ZM9 15.75a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5Z" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-slot:items>
|
||||
<div class="p-2 border-b text-sm font-bold">Order</div>
|
||||
<DropDownMenuItem @click="order = 'a-z'">
|
||||
<input type="radio" :checked="order === 'a-z'"/>
|
||||
<div class="my-auto" :class="{ 'font-bold': order === 'a-z' }">A-Z</div>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem @click="order = 'heard-recently'">
|
||||
<input type="radio" :checked="order === 'heard-recently'"/>
|
||||
<div class="my-auto" :class="[ order === 'heard-recently' ? 'font-bold' : '' ]">Heard Recently</div>
|
||||
</DropDownMenuItem>
|
||||
<div class="p-2 border-b text-sm font-bold">Filter</div>
|
||||
<DropDownMenuItem @click="filter = 'all'">
|
||||
<input type="radio" :checked="filter === 'all'"/>
|
||||
<div class="my-auto" :class="[ filter === 'all' ? 'font-bold' : '' ]">All</div>
|
||||
</DropDownMenuItem>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- contacts -->
|
||||
<div class="h-full overflow-y-auto">
|
||||
<ContactListItem :key="contact.publicKey" v-for="contact of searchedContacts" :contact="contact" @click="onContactClick(contact)"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GlobalState from "../../js/GlobalState.js";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import ContactListItem from "./ContactListItem.vue";
|
||||
|
||||
export default {
|
||||
name: 'ContactsList',
|
||||
components: {
|
||||
ContactListItem,
|
||||
DropDownMenuItem,
|
||||
DropDownMenu,
|
||||
IconButton,
|
||||
},
|
||||
emits: [
|
||||
"contact-click",
|
||||
],
|
||||
props: {
|
||||
contacts: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
filter: window.localStorage.getItem("contacts_list_filter") ?? "all",
|
||||
order: window.localStorage.getItem("contacts_list_order") ?? "heard-recently",
|
||||
contactsSearchTerm: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onContactClick(contact) {
|
||||
this.$emit("contact-click", contact);
|
||||
},
|
||||
getContactsOrderedByName(contacts) {
|
||||
// sort contacts by name asc (using a shallow copy to ensure it updates automatically)
|
||||
return contacts.sort((contactA, contactB) => {
|
||||
const contactAName = contactA.advName;
|
||||
const contactBName = contactB.advName;
|
||||
return contactAName.localeCompare(contactBName);
|
||||
});
|
||||
},
|
||||
getContactsOrderedByRecentlyHeard(contacts) {
|
||||
// sort contacts by latest advert desc (using a shallow copy to ensure it updates automatically)
|
||||
return contacts.sort((contactA, contactB) => {
|
||||
const contactALastHeard = contactA.lastAdvert;
|
||||
const contactBLastHeard = contactB.lastAdvert;
|
||||
return contactBLastHeard - contactALastHeard;
|
||||
});
|
||||
},
|
||||
getOrderedContacts(contacts) {
|
||||
|
||||
// get ordered contacts
|
||||
var orderedContacts = [];
|
||||
switch(this.order){
|
||||
case "a-z": {
|
||||
orderedContacts = this.getContactsOrderedByName(contacts);
|
||||
break;
|
||||
}
|
||||
case "heard-recently": {
|
||||
orderedContacts = this.getContactsOrderedByRecentlyHeard(contacts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return orderedContacts;
|
||||
|
||||
},
|
||||
getFilteredContacts(contacts) {
|
||||
|
||||
// todo filter by chat, repeater, room etc
|
||||
|
||||
// fallback to returning all contacts
|
||||
return contacts;
|
||||
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
GlobalState() {
|
||||
return GlobalState;
|
||||
},
|
||||
searchedContacts() {
|
||||
|
||||
// filter and sort contacts
|
||||
var contacts = [...this.contacts];
|
||||
contacts = this.getFilteredContacts(contacts);
|
||||
contacts = this.getOrderedContacts(contacts);
|
||||
contacts = contacts.filter((contact) => contact != null);
|
||||
|
||||
// search contacts
|
||||
contacts = contacts.filter((contact) => {
|
||||
const search = this.contactsSearchTerm.toLowerCase();
|
||||
const matchesName = contact.advName.toLowerCase().includes(search);
|
||||
return matchesName;
|
||||
});
|
||||
|
||||
return contacts;
|
||||
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filter() {
|
||||
window.localStorage.setItem("contacts_list_filter", this.filter);
|
||||
},
|
||||
order() {
|
||||
window.localStorage.setItem("contacts_list_order", this.order);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
28
src/components/pages/ConnectPage.vue
Normal file
28
src/components/pages/ConnectPage.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Page>
|
||||
|
||||
<!-- app bar -->
|
||||
<AppBar title="Connect" subtitle="Select a Connection Method"/>
|
||||
|
||||
<!-- list -->
|
||||
<div class="mx-auto my-auto">
|
||||
<ConnectButtons/>
|
||||
</div>
|
||||
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppBar from "../AppBar.vue";
|
||||
import Page from "./Page.vue";
|
||||
import ConnectButtons from "../connect/ConnectButtons.vue";
|
||||
|
||||
export default {
|
||||
name: 'ConnectPage',
|
||||
components: {
|
||||
ConnectButtons,
|
||||
Page,
|
||||
AppBar,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
49
src/components/pages/MainPage.vue
Normal file
49
src/components/pages/MainPage.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<Page>
|
||||
|
||||
<!-- header -->
|
||||
<Header/>
|
||||
|
||||
<!-- tab content -->
|
||||
<div v-if="contacts.length > 0" class="flex h-full w-full overflow-hidden">
|
||||
<ContactsList :contacts="contacts" @contact-click="onContactClick"/>
|
||||
</div>
|
||||
|
||||
<!-- not connected and no content -->
|
||||
<div v-if="!GlobalState.connection && contacts.length === 0" class="mx-auto my-auto">
|
||||
<ConnectButtons/>
|
||||
</div>
|
||||
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Header from "../Header.vue";
|
||||
import Page from "./Page.vue";
|
||||
import GlobalState from "../../js/GlobalState.js";
|
||||
import ConnectButtons from "../connect/ConnectButtons.vue";
|
||||
import ContactsList from "../contacts/ContactsList.vue";
|
||||
|
||||
export default {
|
||||
name: 'MainPage',
|
||||
components: {
|
||||
ContactsList,
|
||||
ConnectButtons,
|
||||
Page,
|
||||
Header,
|
||||
},
|
||||
methods: {
|
||||
onContactClick(contact) {
|
||||
// todo
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
GlobalState() {
|
||||
return GlobalState;
|
||||
},
|
||||
contacts() {
|
||||
return GlobalState.contacts;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
11
src/components/pages/Page.vue
Normal file
11
src/components/pages/Page.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full max-w-2xl mx-auto border-x bg-gray-300">
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Page',
|
||||
}
|
||||
</script>
|
||||
12
src/index.html
Normal file
12
src/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>MeshCore</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="bg-gray-100"></div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
64
src/js/Connection.js
Normal file
64
src/js/Connection.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import GlobalState from "./GlobalState.js";
|
||||
import {BleConnection, Constants, SerialConnection} from "@liamcottle/meshcore.js";
|
||||
|
||||
class Connection {
|
||||
|
||||
static async connectViaBluetooth() {
|
||||
await this.connect(await BleConnection.open());
|
||||
}
|
||||
|
||||
static async connectViaSerial() {
|
||||
await this.connect(await SerialConnection.open());
|
||||
}
|
||||
|
||||
static async connect(connection) {
|
||||
GlobalState.connection = connection;
|
||||
GlobalState.connection.on("connected", () => this.onConnected());
|
||||
GlobalState.connection.on("disconnected", () => this.onDisconnected());
|
||||
}
|
||||
|
||||
static async disconnect() {
|
||||
|
||||
// disconnect
|
||||
GlobalState.connection?.close();
|
||||
|
||||
// update ui
|
||||
GlobalState.connection = null;
|
||||
|
||||
}
|
||||
|
||||
static async onConnected() {
|
||||
|
||||
// clear previous connection state
|
||||
GlobalState.contacts = [];
|
||||
|
||||
GlobalState.connection.on(Constants.PushCodes.Advert, async () => {
|
||||
console.log("Advert");
|
||||
await this.loadContacts();
|
||||
});
|
||||
|
||||
GlobalState.connection.on(Constants.PushCodes.PathUpdated, async (event) => {
|
||||
console.log("PathUpdated", event);
|
||||
await this.loadContacts();
|
||||
});
|
||||
|
||||
await this.loadSelfInfo();
|
||||
await this.loadContacts();
|
||||
|
||||
}
|
||||
|
||||
static async onDisconnected() {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
static async loadSelfInfo() {
|
||||
GlobalState.selfInfo = await GlobalState.connection.getSelfInfo();
|
||||
}
|
||||
|
||||
static async loadContacts() {
|
||||
GlobalState.contacts = await GlobalState.connection.getContacts();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Connection;
|
||||
10
src/js/GlobalState.js
Normal file
10
src/js/GlobalState.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import {reactive} from "vue";
|
||||
|
||||
// global state
|
||||
const globalState = reactive({
|
||||
connection: null,
|
||||
selfInfo: null,
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
export default globalState;
|
||||
63
src/js/TimeUtils.js
Normal file
63
src/js/TimeUtils.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import moment from "moment";
|
||||
|
||||
class TimeUtils {
|
||||
|
||||
static formatUnixSecondsAgo(unixSeconds) {
|
||||
|
||||
// check if date is known
|
||||
if(unixSeconds > 0){
|
||||
return TimeUtils.getTimeAgoShortHand(moment.unix(unixSeconds).toDate());
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
|
||||
}
|
||||
|
||||
static getTimeAgoShortHand(date) {
|
||||
|
||||
// get duration between now and provided date
|
||||
const duration = moment.duration(moment().diff(date));
|
||||
|
||||
// years
|
||||
const years = Math.floor(duration.asYears());
|
||||
if(years > 0){
|
||||
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
|
||||
// months
|
||||
const months = Math.floor(duration.asMonths());
|
||||
if(months > 0){
|
||||
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||
}
|
||||
|
||||
// weeks
|
||||
const weeks = Math.floor(duration.asWeeks());
|
||||
if(weeks > 0){
|
||||
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||
}
|
||||
|
||||
// days
|
||||
const days = Math.floor(duration.asDays());
|
||||
if(days > 0){
|
||||
return `${days} ${days === 1 ? 'day' : 'days'} ago`;
|
||||
}
|
||||
|
||||
// hours
|
||||
const hours = Math.floor(duration.asHours());
|
||||
if(hours > 0){
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
|
||||
}
|
||||
|
||||
// minutes
|
||||
const minutes = Math.floor(duration.asMinutes());
|
||||
if(minutes > 0){
|
||||
return `${minutes} ${minutes === 1 ? 'min' : 'mins'} ago`;
|
||||
}
|
||||
|
||||
return "now";
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export default TimeUtils;
|
||||
27
src/main.js
Normal file
27
src/main.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import vClickOutside from "click-outside-vue3";
|
||||
import "./style.css";
|
||||
|
||||
import App from './components/App.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
name: "main",
|
||||
path: '/',
|
||||
component: () => import("./components/pages/MainPage.vue"),
|
||||
},
|
||||
{
|
||||
name: "connect",
|
||||
path: '/connect',
|
||||
component: () => import("./components/pages/ConnectPage.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
3
src/style.css
Normal file
3
src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
17
tailwind.config.js
Normal file
17
tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import formsPlugin from '@tailwindcss/forms';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./src/index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
formsPlugin,
|
||||
],
|
||||
};
|
||||
20
vite.config.js
Normal file
20
vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import path from "path";
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default {
|
||||
|
||||
// vite app is loaded from /src
|
||||
root: path.join(__dirname, "src"),
|
||||
|
||||
// build to /dist instead of /src/dist
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
|
||||
// add plugins
|
||||
plugins: [
|
||||
vue(),
|
||||
],
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user