start implementing client based on meshtxt

This commit is contained in:
liamcottle
2025-02-13 01:02:27 +13:00
parent fd680c2aeb
commit 6d4e7e4431
24 changed files with 3279 additions and 3 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/.idea
/node_modules

2428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,25 @@
"name": "meshcore-web", "name": "meshcore-web",
"version": "0.0.1", "version": "0.0.1",
"description": "A web based MeshCore client developed by Liam Cottle", "description": "A web based MeshCore client developed by Liam Cottle",
"main": "index.js", "main": "src/main.js",
"type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "build": "vite build",
"dev": "vite"
}, },
"author": "Liam Cottle <liam@liamcottle.com>", "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
View File

@@ -0,0 +1,10 @@
export default {
plugins: {
tailwindcss: {
},
autoprefixer: {
},
},
}

12
src/components/App.vue Normal file
View 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
View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

17
tailwind.config.js Normal file
View 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
View 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(),
],
}