Files
reticulum-meshchatX/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue

282 lines
13 KiB
Vue

<template>
<div class="flex flex-col w-80 min-w-80 min-h-0 bg-white/90 dark:bg-zinc-950/80 backdrop-blur border-r border-gray-200 dark:border-zinc-800">
<div class="flex">
<button @click="tab = 'favourites'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'favourites' }">
Favourites
</button>
<button @click="tab = 'announces'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'announces' }">
Announces
</button>
</div>
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} favourites...`" class="input-field"/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
<div
v-for="favourite of searchedFavourites"
:key="favourite.destination_hash"
@click="onFavouriteClick(favourite)"
class="favourite-card"
:class="[
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : ''
]"
draggable="true"
@dragstart="onFavouriteDragStart($event, favourite)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, favourite)"
@dragend="onFavouriteDragEnd"
>
<div class="favourite-card__icon">
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5"/>
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="favourite.display_name">{{ favourite.display_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ formatDestinationHash(favourite.destination_hash) }}</div>
</div>
<DropDownMenu>
<template #button>
<IconButton class="bg-transparent dark:bg-transparent w-8 h-8 p-0 flex items-center justify-center">
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
<span>Rename</span>
</DropDownMenuItem>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500"/>
<span class="text-red-500">Remove</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8"/>
<div class="font-semibold">No favourites</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Add nodes from the announces tab.</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input v-model="nodesSearchTerm" type="text" placeholder="Search announces" class="input-field"/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
<div v-for="node of searchedNodes" :key="node.destination_hash" @click="onNodeClick(node)" class="announce-card" :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }">
<div class="announce-card__icon">
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5"/>
</div>
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="node.display_name">{{ node.display_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Announced {{ formatTimeAgo(node.updated_at) }}</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="radar" class="w-8 h-8"/>
<div class="font-semibold">No announces yet</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Listening for peers on the mesh.</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default {
name: 'NomadNetworkSidebar',
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
props: {
nodes: Object,
favourites: Array,
selectedDestinationHash: String,
},
data() {
return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "",
favouritesOrder: [],
draggingFavouriteHash: null,
};
},
mounted() {
this.loadFavouriteOrder();
this.ensureFavouriteOrder();
},
watch: {
favourites: {
handler() {
this.ensureFavouriteOrder();
},
deep: true,
},
},
methods: {
onNodeClick(node) {
this.$emit("node-click", node);
},
onFavouriteClick(favourite) {
this.onNodeClick(favourite);
},
onRenameFavourite(favourite) {
this.$emit("rename-favourite", favourite);
},
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
loadFavouriteOrder() {
try {
const stored = localStorage.getItem("meshchat.nomadnet.favourites");
if(stored){
this.favouritesOrder = JSON.parse(stored);
}
} catch(e) {
console.log(e);
}
},
persistFavouriteOrder() {
localStorage.setItem("meshchat.nomadnet.favourites", JSON.stringify(this.favouritesOrder));
},
ensureFavouriteOrder() {
const hashes = this.favourites.map((fav) => fav.destination_hash);
const nextOrder = this.favouritesOrder.filter((hash) => hashes.includes(hash));
hashes.forEach((hash) => {
if(!nextOrder.includes(hash)){
nextOrder.push(hash);
}
});
if(JSON.stringify(nextOrder) !== JSON.stringify(this.favouritesOrder)){
this.favouritesOrder = nextOrder;
this.persistFavouriteOrder();
}
},
onFavouriteDragStart(event, favourite) {
try {
if(event?.dataTransfer){
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", favourite.destination_hash);
}
} catch(e) {
// ignore for browsers that prevent setting drag meta
}
this.draggingFavouriteHash = favourite.destination_hash;
},
onFavouriteDragOver(event) {
if(event?.dataTransfer){
event.dataTransfer.dropEffect = "move";
}
},
onFavouriteDrop(event, targetFavourite) {
if(!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash){
return;
}
const fromIndex = this.favouritesOrder.indexOf(this.draggingFavouriteHash);
const toIndex = this.favouritesOrder.indexOf(targetFavourite.destination_hash);
if(fromIndex === -1 || toIndex === -1){
return;
}
const updated = [...this.favouritesOrder];
updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, this.draggingFavouriteHash);
this.favouritesOrder = updated;
this.persistFavouriteOrder();
this.draggingFavouriteHash = null;
},
onFavouriteDragEnd() {
this.draggingFavouriteHash = null;
},
formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
formatDestinationHash: function(destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
},
computed: {
nodesCount() {
return Object.keys(this.nodes).length;
},
nodesOrderedByLatestAnnounce() {
const nodes = Object.values(this.nodes);
return nodes.sort(function(nodeA, nodeB) {
// order by updated_at desc
const nodeAUpdatedAt = new Date(nodeA.updated_at).getTime();
const nodeBUpdatedAt = new Date(nodeB.updated_at).getTime();
return nodeBUpdatedAt - nodeAUpdatedAt;
});
},
searchedNodes() {
return this.nodesOrderedByLatestAnnounce.filter((node) => {
const search = this.nodesSearchTerm.toLowerCase();
const matchesDisplayName = node.display_name.toLowerCase().includes(search);
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesDestinationHash;
});
},
orderedFavourites() {
return [...this.favourites].sort((a, b) => {
return this.favouritesOrder.indexOf(a.destination_hash) - this.favouritesOrder.indexOf(b.destination_hash);
});
},
searchedFavourites() {
return this.orderedFavourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
},
}
</script>
<style scoped>
.sidebar-tab {
@apply w-1/2 py-3 text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
}
.sidebar-tab--active {
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;
}
.favourite-card {
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
}
.favourite-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/60 dark:bg-blue-900/30;
}
.favourite-card__icon,
.announce-card__icon {
@apply w-10 h-10 rounded-xl bg-gray-100 dark:bg-zinc-800 flex items-center justify-center text-gray-500 dark:text-gray-300;
}
.favourite-card--dragging {
@apply opacity-60 ring-2 ring-blue-300 dark:ring-blue-600;
}
.announce-card {
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
}
.announce-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
}
.empty-state {
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
}
</style>