981 lines
41 KiB
Vue
981 lines
41 KiB
Vue
<template>
|
|
|
|
<!-- nomadnetwork sidebar -->
|
|
<NomadNetworkSidebar
|
|
:nodes="nodes"
|
|
:favourites="favourites"
|
|
:selected-destination-hash="selectedNode?.destination_hash"
|
|
@node-click="onNodeClick"
|
|
@rename-favourite="onRenameFavourite"
|
|
@remove-favourite="onRemoveFavourite"/>
|
|
|
|
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
|
<!-- node -->
|
|
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
|
<!-- header -->
|
|
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
|
|
|
<!-- favourite button -->
|
|
<div class="my-auto mr-2">
|
|
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
|
|
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
|
<div>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
|
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
|
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
|
<div>
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- node info -->
|
|
<div class="my-auto dark:text-gray-100">
|
|
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
|
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
|
</div>
|
|
|
|
<!-- identify button -->
|
|
<div class="my-auto ml-auto mr-2">
|
|
<div @click="identify(selectedNode.destination_hash)" class="cursor-pointer">
|
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
|
<div>
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- close button -->
|
|
<div class="my-auto mr-2">
|
|
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
|
<div>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- browser navigation -->
|
|
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
|
|
<button @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
<button @click="reloadNodePage" type="button" class="ml-1 my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
<button @click="toggleNodePageSource" type="button" title="Toggle Source Code" class="ml-1 my-auto text-gray-500 dark:text-gray-300 rounded p-1 cursor-pointer" :class="[ isShowingNodePageSource ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' ]">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
|
</svg>
|
|
</button>
|
|
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
<div class="my-auto mx-2 w-full">
|
|
<input v-model="nodePagePathUrlInput" @keyup.enter="onNodePageUrlClick(nodePagePathUrlInput)" type="text" placeholder="Enter Destination URL" class="bg-gray-50 dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 py-1.5 dark:placeholder-gray-400">
|
|
</div>
|
|
<button @click="onNodePageUrlClick(nodePagePathUrlInput)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- page content -->
|
|
<div class="h-full overflow-y-scroll p-3 bg-black text-white nodeContainer">
|
|
<div class="flex" v-if="isLoadingNodePage">
|
|
<div class="my-auto">
|
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
|
|
</div>
|
|
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
|
</div>
|
|
|
|
<!-- file download bottom bar -->
|
|
<div v-if="isDownloadingNodeFile" class="flex w-full border-gray-300 dark:border-zinc-800 border-t p-2 dark:text-gray-100">
|
|
<div class="my-auto mr-2">
|
|
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- no node selected -->
|
|
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5 dark:text-gray-100">
|
|
<div class="mx-auto mb-1">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 dark:text-gray-300">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
|
</svg>
|
|
</div>
|
|
<div class="font-semibold">No Active Node</div>
|
|
<div>Select a Node to start browsing!</div>
|
|
<div class="mx-auto mt-2">
|
|
<button @click.stop="openUrl" type="button"
|
|
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
|
|
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
|
Open a Nomadnet URL
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<style>
|
|
|
|
pre {
|
|
font-family: Roboto Mono Nerd Font, monospace;
|
|
line-height: normal;
|
|
}
|
|
|
|
pre.text-wrap > div {
|
|
display: flex;
|
|
white-space: pre;
|
|
}
|
|
|
|
pre.text-wrap > div > :last-child {
|
|
width: 100%;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
pre a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
</style>
|
|
|
|
<script>
|
|
|
|
import MicronParser from "micron-parser";
|
|
import DialogUtils from "../../js/DialogUtils";
|
|
import WebSocketConnection from "../../js/WebSocketConnection";
|
|
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
|
import GlobalEmitter from "../../js/GlobalEmitter";
|
|
|
|
export default {
|
|
name: 'NomadNetworkPage',
|
|
components: {
|
|
NomadNetworkSidebar,
|
|
},
|
|
props: {
|
|
destinationHash: String,
|
|
},
|
|
data() {
|
|
return {
|
|
|
|
reloadInterval: null,
|
|
|
|
nodes: {},
|
|
selectedNode: null,
|
|
selectedNodePath: null,
|
|
|
|
favourites: [],
|
|
|
|
isLoadingNodePage: false,
|
|
isShowingNodePageSource: false,
|
|
defaultNodePagePath: "/page/index.mu",
|
|
nodePageRequestSequence: 0,
|
|
nodePagePath: null,
|
|
nodePagePathUrlInput: null,
|
|
nodePageContent: null,
|
|
nodePageProgress: 0,
|
|
nodePagePathHistory: [],
|
|
nodePageCache: {},
|
|
|
|
isDownloadingNodeFile: false,
|
|
nodeFilePath: null,
|
|
nodeFileProgress: 0,
|
|
|
|
nomadnetPageDownloadCallbacks: {},
|
|
nomadnetFileDownloadCallbacks: {},
|
|
|
|
};
|
|
},
|
|
beforeUnmount() {
|
|
|
|
clearInterval(this.reloadInterval);
|
|
|
|
// stop listening for websocket messages
|
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
|
|
|
// stop listening for element clicks
|
|
window.document.removeEventListener('click', this.onElementClick);
|
|
|
|
},
|
|
mounted() {
|
|
|
|
// listen for websocket messages
|
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
|
|
|
// listen for element clicks
|
|
window.document.addEventListener('click', this.onElementClick);
|
|
|
|
// load nomadnetwork node if a destination hash was provided on page load
|
|
if(this.destinationHash){
|
|
(async () => {
|
|
// fetch updated announce as we are probably loading node page before we loaded the announces list
|
|
await this.getNomadnetworkNodeAnnounce(this.destinationHash);
|
|
await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
|
|
})();
|
|
}
|
|
|
|
this.getFavourites();
|
|
this.getNomadnetworkNodeAnnounces();
|
|
|
|
// update info every few seconds
|
|
this.reloadInterval = setInterval(() => {
|
|
this.getFavourites();
|
|
}, 5000);
|
|
|
|
},
|
|
methods: {
|
|
onElementClick(event) {
|
|
|
|
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
|
const element = event.target.closest('[data-action="openNode"]');
|
|
if(!element){
|
|
return;
|
|
}
|
|
|
|
// get the destination and fields
|
|
const destination = element.getAttribute("data-destination");
|
|
const fields = element.getAttribute("data-fields");
|
|
|
|
// navigate to destination
|
|
this.onNodePageUrlClick(destination, fields);
|
|
|
|
},
|
|
async onWebsocketMessage(message) {
|
|
const json = JSON.parse(message.data);
|
|
switch(json.type){
|
|
case 'announce': {
|
|
const aspect = json.announce.aspect;
|
|
if(aspect === "nomadnetwork.node"){
|
|
this.updateNodeFromAnnounce(json.announce);
|
|
}
|
|
break;
|
|
}
|
|
case 'nomadnet.page.download': {
|
|
|
|
// get data from server
|
|
const nomadnetPageDownload = json.nomadnet_page_download;
|
|
|
|
// find download callbacks
|
|
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
|
|
const nomadnetPageDownloadCallback = this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
|
if(!nomadnetPageDownloadCallback){
|
|
console.log("did not find nomadnet page download callback for key: " + getNomadnetPageDownloadCallbackKey);
|
|
return;
|
|
}
|
|
|
|
// handle success
|
|
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
|
|
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
|
|
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle failure
|
|
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
|
|
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
|
|
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle progress
|
|
if(nomadnetPageDownload.status === "progress" && nomadnetPageDownloadCallback.onProgressCallback){
|
|
nomadnetPageDownloadCallback.onProgressCallback(nomadnetPageDownload.progress);
|
|
return;
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
case 'nomadnet.file.download': {
|
|
|
|
// get data from server
|
|
const nomadnetFileDownload = json.nomadnet_file_download;
|
|
|
|
// find download callbacks
|
|
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
|
|
const nomadnetFileDownloadCallback = this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
|
if(!nomadnetFileDownloadCallback){
|
|
console.log("did not find nomadnet file download callback for key: " + getNomadnetFileDownloadCallbackKey);
|
|
return;
|
|
}
|
|
|
|
// handle success
|
|
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
|
|
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
|
|
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle failure
|
|
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
|
|
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
|
|
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
|
return;
|
|
}
|
|
|
|
// handle progress
|
|
if(nomadnetFileDownload.status === "progress" && nomadnetFileDownloadCallback.onProgressCallback){
|
|
nomadnetFileDownloadCallback.onProgressCallback(nomadnetFileDownload.progress);
|
|
return;
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
}
|
|
},
|
|
onDestinationPathClick: function(path) {
|
|
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
|
|
},
|
|
async getFavourites() {
|
|
try {
|
|
const response = await window.axios.get("/api/v1/favourites", {
|
|
params: {
|
|
aspect: "nomadnetwork.node",
|
|
},
|
|
});
|
|
this.favourites = response.data.favourites;
|
|
} catch(e) {
|
|
// do nothing if failed to load favourites
|
|
console.log(e);
|
|
}
|
|
},
|
|
isFavourite(destinationHash) {
|
|
return this.favourites.find((favourite) => {
|
|
return favourite.destination_hash === destinationHash;
|
|
}) != null;
|
|
},
|
|
async addFavourite(node) {
|
|
|
|
// add to favourites
|
|
try {
|
|
await window.axios.post("/api/v1/favourites/add", {
|
|
destination_hash: node.destination_hash,
|
|
display_name: node.display_name,
|
|
aspect: "nomadnetwork.node",
|
|
});
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
|
|
// update favourites
|
|
this.getFavourites();
|
|
|
|
},
|
|
async removeFavourite(node) {
|
|
|
|
// remove from favourites
|
|
try {
|
|
await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`);
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
|
|
// update favourites
|
|
this.getFavourites();
|
|
|
|
},
|
|
async getNomadnetworkNodeAnnounces() {
|
|
try {
|
|
|
|
// fetch announces for "nomadnetwork.node" aspect
|
|
const response = await window.axios.get(`/api/v1/announces`, {
|
|
params: {
|
|
aspect: "nomadnetwork.node",
|
|
limit: 500, // limit ui to showing 500 latest announces
|
|
},
|
|
});
|
|
|
|
// update ui
|
|
const nodeAnnounces = response.data.announces;
|
|
for(const nodeAnnounce of nodeAnnounces){
|
|
this.updateNodeFromAnnounce(nodeAnnounce);
|
|
}
|
|
|
|
} catch(e) {
|
|
// do nothing if failed to load announces
|
|
console.log(e);
|
|
}
|
|
},
|
|
async getNomadnetworkNodeAnnounce(destinationHash) {
|
|
try {
|
|
|
|
// fetch announces for "nomadnetwork.node" aspect
|
|
const response = await window.axios.get(`/api/v1/announces`, {
|
|
params: {
|
|
destination_hash: destinationHash,
|
|
limit: 1,
|
|
},
|
|
});
|
|
|
|
// update ui
|
|
const nodeAnnounces = response.data.announces;
|
|
for(const nodeAnnounce of nodeAnnounces){
|
|
this.updateNodeFromAnnounce(nodeAnnounce);
|
|
}
|
|
|
|
} catch(e) {
|
|
// do nothing if failed to load announce
|
|
console.log(e);
|
|
}
|
|
},
|
|
updateNodeFromAnnounce: function(announce) {
|
|
this.nodes[announce.destination_hash] = announce;
|
|
},
|
|
async openUrl() {
|
|
|
|
// ask for url
|
|
const url = await DialogUtils.prompt("Enter a Nomadnet URL");
|
|
if(!url){
|
|
return;
|
|
}
|
|
|
|
// navigate to the url
|
|
await this.onNodePageUrlClick(url);
|
|
|
|
},
|
|
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
|
|
|
// update current route
|
|
this.$router.replace({
|
|
name: "nomadnetwork",
|
|
params: {
|
|
destinationHash: destinationHash,
|
|
},
|
|
});
|
|
|
|
// get new sequence for this page load
|
|
const seq = ++this.nodePageRequestSequence;
|
|
|
|
// get previous page path
|
|
const previousNodePagePath = this.nodePagePath;
|
|
|
|
// update ui
|
|
this.isLoadingNodePage = true;
|
|
this.nodePagePath = `${destinationHash}:${pagePath}`;
|
|
this.nodePageContent = null;
|
|
this.nodePageProgress = 0;
|
|
|
|
// update url bar
|
|
this.nodePagePathUrlInput = this.nodePagePath;
|
|
|
|
// update node path
|
|
this.getNodePath(destinationHash);
|
|
|
|
// add to previous page to history if we are not loading that previous page
|
|
if(addToHistory && previousNodePagePath != null && previousNodePagePath !== this.nodePagePath){
|
|
this.nodePagePathHistory.push(previousNodePagePath);
|
|
}
|
|
|
|
// check if we can load this page from the cache
|
|
if(loadFromCache){
|
|
|
|
// load from cache
|
|
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
|
|
const cachedNodePageContent = this.nodePageCache[nodePagePathCacheKey];
|
|
|
|
// if page is cache, we can just return it now
|
|
if(cachedNodePageContent != null){
|
|
this.nodePageContent = cachedNodePageContent;
|
|
this.renderPageContent(pagePath, cachedNodePageContent);
|
|
this.isLoadingNodePage = false;
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
|
|
|
|
// do nothing if callback is for a previous request
|
|
if(seq !== this.nodePageRequestSequence){
|
|
console.log("ignoring page content callback for previous page request")
|
|
return;
|
|
}
|
|
|
|
// update page content
|
|
this.nodePageContent = pageContent;
|
|
|
|
// update cache
|
|
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
|
|
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
|
|
|
|
// update page content
|
|
this.renderPageContent(pagePath, pageContent);
|
|
this.isLoadingNodePage = false;
|
|
|
|
// update node path
|
|
this.getNodePath(destinationHash);
|
|
|
|
}, (failureReason) => {
|
|
|
|
// do nothing if callback is for a previous request
|
|
if(seq !== this.nodePageRequestSequence){
|
|
console.log("ignoring failure callback for previous page request")
|
|
return;
|
|
}
|
|
|
|
// update page content
|
|
this.nodePageContent = `Failed loading page: ${failureReason}`;
|
|
this.isLoadingNodePage = false;
|
|
|
|
// update node path
|
|
this.getNodePath(destinationHash);
|
|
|
|
}, (progress) => {
|
|
|
|
// do nothing if callback is for a previous request
|
|
if(seq !== this.nodePageRequestSequence){
|
|
console.log("ignoring progress callback for previous page request")
|
|
return;
|
|
}
|
|
|
|
// update page content
|
|
this.nodePageProgress = Math.round(progress * 100);
|
|
|
|
});
|
|
},
|
|
renderPageContent(path, content) {
|
|
|
|
// render page content if we aren't viewing source
|
|
if(!this.isShowingNodePageSource){
|
|
|
|
// check if page url ends with .mu but remove page data first
|
|
// address:/page/index.mu`Data=123
|
|
const [ pagePathWithoutData ] = path.split("`");
|
|
|
|
// convert micron to html if page ends with .mu extension
|
|
if(pagePathWithoutData.endsWith(".mu")){
|
|
const muParser = new MicronParser();
|
|
return muParser.convertMicronToHtml(content);
|
|
}
|
|
|
|
}
|
|
|
|
// otherwise, we will just serve the raw content, making sure to prevent injecting html
|
|
return content
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
},
|
|
toggleNodePageSource() {
|
|
this.isShowingNodePageSource = !this.isShowingNodePageSource;
|
|
},
|
|
async reloadNodePage() {
|
|
|
|
// reload current node page without adding to history and without using cache
|
|
this.onNodePageUrlClick(this.nodePagePath, null, false, false);
|
|
|
|
},
|
|
async loadPreviousNodePage() {
|
|
|
|
// get the previous path from history, or do nothing
|
|
const previousNodePagePath = this.nodePagePathHistory.pop();
|
|
if(!previousNodePagePath){
|
|
return;
|
|
}
|
|
|
|
// load the page
|
|
this.onNodePageUrlClick(previousNodePagePath, null, null, true);
|
|
|
|
},
|
|
parseNomadnetworkUrl: function(url) {
|
|
|
|
// parse relative urls
|
|
if(url.startsWith(":")){
|
|
|
|
// remove leading ":"
|
|
var path = url.substring(1);
|
|
|
|
// if page path is empty we should load default page path
|
|
if(path === ""){
|
|
path = this.defaultNodePagePath;
|
|
}
|
|
|
|
return {
|
|
destination_hash: null, // node hash was not in provided url
|
|
path: path,
|
|
};
|
|
|
|
}
|
|
|
|
// parse absolute urls such as 00000000000000000000000000000000:/page/index.mu
|
|
if(url.includes(":")){
|
|
|
|
// parse destination hash and url
|
|
const [destinationHash, ...relativeUrl] = url.split(":");
|
|
|
|
// ensure destination is expected length
|
|
if(destinationHash.length === 32){
|
|
return {
|
|
destination_hash: destinationHash,
|
|
path: relativeUrl.join(":"),
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
// parse node id only
|
|
if(url.length === 32){
|
|
return {
|
|
destination_hash: url,
|
|
path: this.defaultNodePagePath,
|
|
};
|
|
}
|
|
|
|
// unsupported url
|
|
return null;
|
|
|
|
},
|
|
async onNodePageUrlClick(url, options = null, addToHistory = true, useCache = false) {
|
|
|
|
let fieldData = [];
|
|
|
|
if (options === "*") {
|
|
useCache = false; // we want to send another request with the field data
|
|
const inputs = document.querySelectorAll('.nodeContainer input');
|
|
|
|
const inputValues = {};
|
|
|
|
for(const input of inputs){
|
|
if(input.type === 'radio' || input.type === 'checkbox'){
|
|
// Only add if the input is checked
|
|
if(input.checked){
|
|
inputValues[input.name] = input.value;
|
|
}
|
|
} else {
|
|
// For other input types, just get the value
|
|
inputValues[input.name || input.id || input.type] = input.value;
|
|
}
|
|
}
|
|
|
|
fieldData = inputValues;
|
|
} else if(options !== null && options !== "") {
|
|
useCache = false;
|
|
// split options into an array of names
|
|
const validNames = options.split('|');
|
|
|
|
// Select inputs within the container
|
|
const inputs = document.querySelectorAll('.nodeContainer input');
|
|
|
|
const inputValues = {};
|
|
|
|
// Filter inputs by name and handle their values
|
|
for(const input of inputs){
|
|
if(validNames.includes(input.name)){
|
|
if(input.type === 'radio' || input.type === 'checkbox'){
|
|
// Only add if the input is checked
|
|
if(input.checked){
|
|
inputValues[input.name] = input.value;
|
|
}
|
|
} else {
|
|
// For other input types, just get the value
|
|
inputValues[input.name] = input.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
fieldData = inputValues;
|
|
}
|
|
|
|
console.log(fieldData);
|
|
|
|
|
|
// open http urls in new tab
|
|
if(url.startsWith("http://") || url.startsWith("https://")){
|
|
window.open(url, "_blank");
|
|
return;
|
|
}
|
|
|
|
// lxmf urls should open the conversation
|
|
if(url.startsWith("lxmf@")){
|
|
const destinationHash = url.replace("lxmf@", "");
|
|
if(destinationHash.length === 32){
|
|
await this.$router.push({
|
|
name: "messages",
|
|
params: {
|
|
destinationHash: destinationHash,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// attempt to parse url
|
|
const parsedUrl = this.parseNomadnetworkUrl(url);
|
|
if(parsedUrl != null){
|
|
|
|
// use parsed destination hash, or fallback to selected node destination hash
|
|
const destinationHash = parsedUrl.destination_hash || this.selectedNode.destination_hash;
|
|
|
|
// download file
|
|
if(parsedUrl.path.startsWith("/file/")){
|
|
|
|
// prevent simultaneous downloads
|
|
if(this.isDownloadingNodeFile){
|
|
DialogUtils.alert("An existing download is in progress. Please wait for it to finish before starting another download.");
|
|
return;
|
|
}
|
|
|
|
// update ui
|
|
this.isDownloadingNodeFile = true;
|
|
this.nodeFilePath = parsedUrl.path.split("/").pop();
|
|
this.nodeFileProgress = 0;
|
|
|
|
// start file download
|
|
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
|
|
|
|
// no longer downloading
|
|
this.isDownloadingNodeFile = false;
|
|
|
|
// download file to browser
|
|
this.downloadFileFromBase64(fileName, fileBytesBase64);
|
|
|
|
}, (failureReason) => {
|
|
|
|
// no longer downloading
|
|
this.isDownloadingNodeFile = false;
|
|
|
|
// show error message
|
|
DialogUtils.alert(`Failed to download file: ${failureReason}`);
|
|
|
|
}, (progress) => {
|
|
this.nodeFileProgress = Math.round(progress * 100);
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// update selected node, so relative urls work correctly when returned by the new node
|
|
this.selectedNode = this.nodes[destinationHash] || {
|
|
display_name: "Unknown Node",
|
|
destination_hash: destinationHash,
|
|
};
|
|
|
|
// navigate to node page
|
|
this.loadNodePage(destinationHash, parsedUrl.path, fieldData, addToHistory, useCache);
|
|
return;
|
|
|
|
}
|
|
|
|
// unsupported url
|
|
DialogUtils.alert("unsupported url: " + url);
|
|
|
|
},
|
|
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
|
|
|
|
// create blob from base64 encoded file bytes
|
|
const byteCharacters = atob(fileBytesBase64);
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
for(let i = 0; i < byteCharacters.length; i++){
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
const blob = new Blob([byteArray]);
|
|
|
|
// create object url for blob
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
|
|
// create link element to download blob
|
|
const link = document.createElement('a');
|
|
link.href = objectUrl;
|
|
link.download = fileName;
|
|
link.style.display = "none";
|
|
document.body.append(link);
|
|
|
|
// click link to download file in browser
|
|
link.click();
|
|
|
|
// link element is no longer needed
|
|
link.remove();
|
|
|
|
// revoke object url to clear memory
|
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
|
|
|
},
|
|
onNodeClick: function(node) {
|
|
|
|
// update selected node
|
|
this.selectedNode = node;
|
|
|
|
// load default node page
|
|
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
|
|
|
|
},
|
|
async onRenameFavourite(favourite) {
|
|
|
|
// ask user for new display name
|
|
const displayName = await DialogUtils.prompt("Rename this favourite");
|
|
if(displayName == null){
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
// rename on server
|
|
await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
|
|
display_name: displayName,
|
|
});
|
|
|
|
// reload favourites
|
|
await this.getFavourites();
|
|
|
|
} catch(e) {
|
|
console.log(e);
|
|
DialogUtils.alert("Failed to rename favourite");
|
|
}
|
|
|
|
},
|
|
async onRemoveFavourite(favourite) {
|
|
|
|
// ask user to confirm
|
|
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
|
|
return;
|
|
}
|
|
|
|
this.removeFavourite(favourite);
|
|
|
|
},
|
|
onCloseNodeViewer: function() {
|
|
|
|
// clear selected node
|
|
this.selectedNode = null;
|
|
|
|
// update current route
|
|
this.$router.replace({
|
|
name: "nomadnetwork",
|
|
});
|
|
|
|
},
|
|
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
|
return `${destinationHash}:${pagePath}`;
|
|
},
|
|
getNomadnetFileDownloadCallbackKey: function(destinationHash, filePath) {
|
|
return `${destinationHash}:${filePath}`;
|
|
},
|
|
async getNodePath(destinationHash) {
|
|
|
|
// clear previous known path
|
|
this.selectedNodePath = null;
|
|
|
|
try {
|
|
|
|
// get path to destination
|
|
const response = await window.axios.get(`/api/v1/destination/${destinationHash}/path`);
|
|
|
|
// update ui
|
|
this.selectedNodePath = response.data.path;
|
|
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
|
|
},
|
|
async identify(destinationHash) {
|
|
try {
|
|
|
|
// ask user to confirm
|
|
if(!await DialogUtils.confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){
|
|
return;
|
|
}
|
|
|
|
// identify self to nomadnetwork node
|
|
await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`);
|
|
|
|
// reload page
|
|
this.reloadNodePage();
|
|
|
|
} catch(e) {
|
|
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
|
|
}
|
|
},
|
|
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
|
try {
|
|
|
|
// set callbacks for nomadnet filePath download
|
|
this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] = {
|
|
onSuccessCallback: onSuccessCallback,
|
|
onFailureCallback: onFailureCallback,
|
|
onProgressCallback: onProgressCallback,
|
|
};
|
|
|
|
// ask reticulum to download file from nomadnet
|
|
WebSocketConnection.send(JSON.stringify({
|
|
"type": "nomadnet.file.download",
|
|
"nomadnet_file_download": {
|
|
"destination_hash": destinationHash,
|
|
"file_path": filePath,
|
|
},
|
|
}));
|
|
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
},
|
|
downloadNomadNetPage(destinationHash, pagePath, fieldData, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
|
try {
|
|
|
|
// set callbacks for nomadnet page download
|
|
this.nomadnetPageDownloadCallbacks[this.getNomadnetPageDownloadCallbackKey(destinationHash, pagePath)] = {
|
|
onSuccessCallback: onSuccessCallback,
|
|
onFailureCallback: onFailureCallback,
|
|
onProgressCallback: onProgressCallback,
|
|
};
|
|
|
|
// ask reticulum to download page from nomadnet
|
|
WebSocketConnection.send(JSON.stringify({
|
|
"type": "nomadnet.page.download",
|
|
"nomadnet_page_download": {
|
|
"destination_hash": destinationHash,
|
|
"page_path": pagePath,
|
|
"field_data": fieldData,
|
|
},
|
|
}));
|
|
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
},
|
|
renderedNodePageContent() {
|
|
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
|
},
|
|
},
|
|
}
|
|
</script>
|