feat(call): improve call initiation and status handling with new properties for target hash and name; improve UI modals for tutorial and changelog visibility based on URL parameters

This commit is contained in:
2026-01-04 14:59:20 -06:00
parent dda8a58bb3
commit 8fac1134e2
19 changed files with 3195 additions and 1446 deletions

View File

@@ -448,6 +448,8 @@
:was-declined="wasDeclined"
:voicemail-status="voicemailStatus"
:initiation-status="initiationStatus"
:initiation-target-hash="initiationTargetHash"
:initiation-target-name="initiationTargetName"
@hangup="onOverlayHangup"
@toggle-mic="onToggleMic"
@toggle-speaker="onToggleSpeaker"
@@ -564,6 +566,7 @@ export default {
isFetchingRingtone: false,
initiationStatus: null,
initiationTargetHash: null,
initiationTargetName: null,
isCallWindowOpen: false,
};
},
@@ -732,6 +735,7 @@ export default {
case "telephone_initiation_status": {
this.initiationStatus = json.status;
this.initiationTargetHash = json.target_hash;
this.initiationTargetName = json.target_name;
break;
}
case "new_voicemail": {
@@ -788,8 +792,22 @@ export default {
const response = await window.axios.get(`/api/v1/app/info`);
this.appInfo = response.data.app_info;
// check URL params for modal triggers
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("show-guide")) {
this.$refs.tutorialModal.show();
// remove param from URL
urlParams.delete("show-guide");
const newUrl = window.location.pathname + (urlParams.toString() ? `?${urlParams.toString()}` : "");
window.history.replaceState({}, "", newUrl);
} else if (urlParams.has("changelog")) {
this.$refs.changelogModal.show();
// remove param from URL
urlParams.delete("changelog");
const newUrl = window.location.pathname + (urlParams.toString() ? `?${urlParams.toString()}` : "");
window.history.replaceState({}, "", newUrl);
} else if (!this.hasCheckedForModals) {
// check if we should show tutorial or changelog (only on first load)
if (!this.hasCheckedForModals) {
this.hasCheckedForModals = true;
if (this.appInfo && !this.appInfo.tutorial_seen) {
this.$refs.tutorialModal.show();
@@ -1020,6 +1038,7 @@ export default {
this.voicemailStatus = response.data.voicemail;
this.initiationStatus = response.data.initiation_status;
this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name;
// Handle power management for calls
if (ElectronUtils.isElectron()) {

View File

@@ -18,26 +18,21 @@
<v-toolbar-title class="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
{{ $t("app.changelog_title", "What's New") }}
</v-toolbar-title>
<v-chip
<span
v-if="version"
size="x-small"
color="blue"
variant="flat"
class="ml-3 font-black text-[10px] px-2 h-5 tracking-tighter uppercase rounded-sm"
class="ml-3 font-black text-[10px] px-2 h-5 tracking-tighter uppercase rounded-sm bg-blue-600 text-white inline-flex items-center"
>
v{{ version }}
</v-chip>
</span>
</div>
<v-spacer></v-spacer>
<v-btn
icon
size="small"
variant="text"
class="text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10"
<button
type="button"
class="v-btn text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10 p-2 transition-colors"
@click="close"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</button>
</v-toolbar>
<!-- Content -->
@@ -50,15 +45,7 @@
<div v-else-if="error" class="flex flex-col items-center justify-center h-full text-center space-y-4">
<v-icon icon="mdi-alert-circle-outline" size="64" color="red"></v-icon>
<div class="text-red-500 font-bold text-lg">{{ error }}</div>
<v-btn
color="blue"
variant="flat"
class="font-bold uppercase px-6"
rounded="lg"
@click="fetchChangelog"
>
Retry
</v-btn>
<button type="button" class="primary-chip !px-6" @click="fetchChangelog">Retry</button>
</div>
<div
@@ -92,15 +79,9 @@
></v-checkbox>
</div>
<v-spacer></v-spacer>
<v-btn
variant="flat"
color="blue"
class="px-8 font-black tracking-tighter uppercase text-white dark:text-zinc-900"
rounded="xl"
@click="close"
>
<button type="button" class="primary-chip !px-8 !h-10 !rounded-xl" @click="close">
{{ $t("common.close", "Close") }}
</v-btn>
</button>
</v-card-actions>
</v-card>
</v-dialog>
@@ -117,14 +98,11 @@
{{ $t("app.changelog_title", "What's New") }}
</h1>
<div class="flex items-center gap-2">
<v-chip
color="blue"
variant="flat"
size="x-small"
class="font-black text-[10px] px-2 h-5 rounded-sm"
<span
class="font-black text-[10px] px-2 h-5 rounded-sm bg-blue-600 text-white inline-flex items-center"
>
v{{ version }}
</v-chip>
</span>
<span class="text-sm text-gray-500 font-medium">Full release history</span>
</div>
</div>
@@ -137,15 +115,7 @@
<div v-else-if="error" class="flex flex-col items-center justify-center py-20 text-center space-y-4">
<v-icon icon="mdi-alert-circle-outline" size="64" color="red"></v-icon>
<div class="text-red-500 font-bold text-lg">{{ error }}</div>
<v-btn
color="blue"
variant="flat"
class="font-bold uppercase px-6"
rounded="lg"
@click="fetchChangelog"
>
Retry
</v-btn>
<button type="button" class="primary-chip !px-6" @click="fetchChangelog">Retry</button>
</div>
<div v-else class="changelog-content max-w-none prose dark:prose-invert pb-20">

View File

@@ -114,6 +114,7 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import LxmfUserIcon from "./LxmfUserIcon.vue";
import GlobalEmitter from "../js/GlobalEmitter";
import ToastUtils from "../js/ToastUtils";
export default {
name: "CommandPalette",
@@ -174,6 +175,94 @@ export default {
type: "navigation",
route: { name: "settings" },
},
{
id: "nav-ping",
title: "nav_ping",
description: "nav_ping_desc",
icon: "radar",
type: "navigation",
route: { name: "ping" },
},
{
id: "nav-rnprobe",
title: "nav_rnprobe",
description: "nav_rnprobe_desc",
icon: "radar",
type: "navigation",
route: { name: "rnprobe" },
},
{
id: "nav-rncp",
title: "nav_rncp",
description: "nav_rncp_desc",
icon: "swap-horizontal",
type: "navigation",
route: { name: "rncp" },
},
{
id: "nav-rnstatus",
title: "nav_rnstatus",
description: "nav_rnstatus_desc",
icon: "chart-line",
type: "navigation",
route: { name: "rnstatus" },
},
{
id: "nav-rnpath",
title: "nav_rnpath",
description: "nav_rnpath_desc",
icon: "route",
type: "navigation",
route: { name: "rnpath" },
},
{
id: "nav-translator",
title: "nav_translator",
description: "nav_translator_desc",
icon: "translate",
type: "navigation",
route: { name: "translator" },
},
{
id: "nav-forwarder",
title: "nav_forwarder",
description: "nav_forwarder_desc",
icon: "email-send-outline",
type: "navigation",
route: { name: "forwarder" },
},
{
id: "nav-documentation",
title: "nav_documentation",
description: "nav_documentation_desc",
icon: "book-open-variant",
type: "navigation",
route: { name: "documentation" },
},
{
id: "nav-micron-editor",
title: "nav_micron_editor",
description: "nav_micron_editor_desc",
icon: "code-tags",
type: "navigation",
route: { name: "micron-editor" },
},
{
id: "nav-rnode-flasher",
title: "nav_rnode_flasher",
description: "nav_rnode_flasher_desc",
icon: "flash",
type: "navigation",
route: { name: "rnode-flasher" },
},
{
id: "nav-debug-logs",
title: "nav_debug_logs",
description: "nav_debug_logs_desc",
icon: "console",
type: "navigation",
route: { name: "debug-logs" },
},
{
id: "action-sync",
title: "action_sync",
@@ -198,6 +287,22 @@ export default {
type: "action",
action: "toggle-orbit",
},
{
id: "action-getting-started",
title: "action_getting_started",
description: "action_getting_started_desc",
icon: "help-circle",
type: "action",
action: "show-tutorial",
},
{
id: "action-changelog",
title: "action_changelog",
description: "action_changelog_desc",
icon: "history",
type: "action",
action: "show-changelog",
},
],
};
},
@@ -340,7 +445,7 @@ export default {
} else if (result.type === "peer") {
this.$router.push({ name: "messages", params: { destinationHash: result.peer.destination_hash } });
} else if (result.type === "contact") {
this.$router.push({ name: "call", query: { destination_hash: result.contact.remote_identity_hash } });
this.dialContact(result.contact.remote_identity_hash);
} else if (result.type === "action") {
if (result.action === "sync") {
GlobalEmitter.emit("sync-propagation-node");
@@ -352,9 +457,23 @@ export default {
});
} else if (result.action === "toggle-orbit") {
GlobalEmitter.emit("toggle-orbit");
} else if (result.action === "show-tutorial") {
GlobalEmitter.emit("show-tutorial");
} else if (result.action === "show-changelog") {
GlobalEmitter.emit("show-changelog");
}
}
},
async dialContact(hash) {
try {
await window.axios.get(`/api/v1/telephone/call/${hash}`);
if (this.$route.name !== "call") {
this.$router.push({ name: "call" });
}
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to initiate call");
}
},
},
};
</script>

View File

@@ -60,12 +60,13 @@ export default {
};
},
mounted() {
GlobalEmitter.on("toast", (toast) => {
this.toastHandler = (toast) => {
this.add(toast);
});
};
GlobalEmitter.on("toast", this.toastHandler);
},
beforeUnmount() {
GlobalEmitter.off("toast");
GlobalEmitter.off("toast", this.toastHandler);
},
methods: {
add(toast) {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
<div class="flex h-full overflow-hidden bg-white dark:bg-zinc-950">
<!-- Sidebar 1: Nodes -->
<ArchiveSidebar
v-if="!isSidebarHidden"
v-if="!isSidebar1Hidden"
class="w-full sm:w-64 border-r border-gray-200 dark:border-zinc-800 shrink-0"
:class="{ 'hidden sm:flex': selectedNodeHash }"
:nodes="groupedArchives"
@@ -15,7 +15,7 @@
<!-- Sidebar 2: Snapshots -->
<div
v-if="selectedNode"
v-if="selectedNode && !isSidebar2Hidden"
class="w-full sm:w-80 border-r border-gray-200 dark:border-zinc-800 flex flex-col shrink-0 bg-gray-50 dark:bg-zinc-900/50"
:class="{ 'hidden sm:flex': viewingArchive }"
>
@@ -140,6 +140,23 @@
</div>
<div class="flex items-center gap-1">
<button
class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
:class="{ 'text-blue-400': !isSidebar1Hidden, 'text-zinc-600': isSidebar1Hidden }"
:title="isSidebar1Hidden ? 'Show Nodes' : 'Hide Nodes'"
@click="isSidebar1Hidden = !isSidebar1Hidden"
>
<MaterialDesignIcon icon-name="page-layout-sidebar-left" class="size-4" />
</button>
<button
class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
:class="{ 'text-blue-400': !isSidebar2Hidden, 'text-zinc-600': isSidebar2Hidden }"
:title="isSidebar2Hidden ? 'Show Snapshots' : 'Hide Snapshots'"
@click="isSidebar2Hidden = !isSidebar2Hidden"
>
<MaterialDesignIcon icon-name="view-list" class="size-4" />
</button>
<div class="hidden sm:block w-px h-6 bg-zinc-800 mx-1"></div>
<button
class="p-2 hover:bg-zinc-800 rounded transition-colors text-blue-400 flex items-center gap-2"
@click="openInNomadnet(viewingArchive)"
@@ -199,7 +216,8 @@ export default {
muParser: new MicronParser(),
selectedNodeHash: null,
viewingArchive: null,
isSidebarHidden: false,
isSidebar1Hidden: false,
isSidebar2Hidden: false,
renderedContent: "",
searchQuery: "",
selectedArchives: [],
@@ -257,6 +275,8 @@ export default {
}, 10);
} else {
this.renderedContent = "";
this.isSidebar1Hidden = false;
this.isSidebar2Hidden = false;
}
},
},

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="activeCall"
v-if="activeCall || initiationStatus || isEnded || wasDeclined"
class="fixed bottom-4 right-4 z-[100] w-80 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
:class="{ 'ring-2 ring-red-500 ring-opacity-50': isEnded || wasDeclined }"
>
@@ -25,10 +25,12 @@
? $t("call.call_declined")
: isEnded
? $t("call.call_ended")
: activeCall.is_voicemail
: activeCall && activeCall.is_voicemail
? $t("call.recording_voicemail")
: activeCall.status === 6
: activeCall && activeCall.status === 6
? $t("call.active_call")
: initiationStatus
? $t("call.initiation")
: $t("call.call_status")
}}
</span>
@@ -65,27 +67,35 @@
"
>
<LxmfUserIcon
:custom-image="activeCall.custom_image"
:icon-name="activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
:icon-foreground-colour="activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''"
:icon-background-colour="activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''"
:custom-image="activeCall ? activeCall.custom_image : null"
:icon-name="activeCall && activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
:icon-foreground-colour="
activeCall && activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''
"
:icon-background-colour="
activeCall && activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''
"
icon-class="size-14"
/>
</div>
<div class="text-center w-full min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate px-2">
{{ activeCall.remote_identity_name || $t("call.unknown") }}
{{
(activeCall ? activeCall.remote_identity_name : initiationTargetName) || $t("call.unknown")
}}
</div>
<div
v-if="activeCall.is_contact"
v-if="activeCall ? activeCall.is_contact : !!initiationTargetName"
class="text-[10px] text-blue-600 dark:text-blue-400 font-medium mt-0.5"
>
In contacts
</div>
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate px-4">
{{
activeCall.remote_identity_hash
? formatDestinationHash(activeCall.remote_identity_hash)
(activeCall ? activeCall.remote_identity_hash : initiationTargetHash)
? formatDestinationHash(
activeCall ? activeCall.remote_identity_hash : initiationTargetHash
)
: ""
}}
</div>
@@ -242,18 +252,24 @@
>
<div class="flex items-center space-x-2 overflow-hidden mr-2 min-w-0">
<LxmfUserIcon
:custom-image="activeCall.custom_image"
:icon-name="activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
:icon-foreground-colour="activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''"
:icon-background-colour="activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''"
:custom-image="activeCall ? activeCall.custom_image : null"
:icon-name="activeCall && activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
:icon-foreground-colour="
activeCall && activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''
"
:icon-background-colour="
activeCall && activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''
"
icon-class="size-6 shrink-0"
/>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate block">
{{ activeCall.remote_identity_name || $t("call.unknown") }}
{{
(activeCall ? activeCall.remote_identity_name : initiationTargetName) || $t("call.unknown")
}}
</span>
<span
v-if="activeCall.status === 6 && elapsedTime"
v-if="activeCall && activeCall.status === 6 && elapsedTime"
class="text-[10px] text-gray-500 dark:text-zinc-400 font-mono"
>
{{ elapsedTime }}
@@ -297,7 +313,7 @@ export default {
props: {
activeCall: {
type: Object,
required: true,
default: null,
},
isEnded: {
type: Boolean,
@@ -315,6 +331,14 @@ export default {
type: String,
default: null,
},
initiationTargetHash: {
type: String,
default: null,
},
initiationTargetName: {
type: String,
default: null,
},
},
emits: ["hangup", "toggle-mic", "toggle-speaker"],
data() {

View File

@@ -141,16 +141,24 @@
<div class="relative z-10 space-y-1 mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white truncate max-w-[280px]">
{{ (activeCall || lastCall)?.remote_identity_name || $t("call.unknown") }}
{{
(activeCall || lastCall)?.remote_identity_name ||
initiationTargetName ||
$t("call.unknown")
}}
</h2>
<div
v-if="(activeCall || lastCall)?.remote_identity_hash"
v-if="(activeCall || lastCall)?.remote_identity_hash || initiationTargetHash"
class="text-xs font-mono text-gray-400 dark:text-zinc-500 tracking-wider"
>
{{ formatDestinationHash((activeCall || lastCall).remote_identity_hash) }}
{{
formatDestinationHash(
(activeCall || lastCall)?.remote_identity_hash || initiationTargetHash
)
}}
</div>
<div
v-if="(activeCall || lastCall)?.is_contact"
v-if="(activeCall || lastCall)?.is_contact || !!initiationTargetName"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-[10px] font-bold rounded-full uppercase tracking-wider"
>
<MaterialDesignIcon icon-name="check-decagram" class="size-3" />
@@ -2001,6 +2009,7 @@ export default {
localSpeakerMuted: false,
initiationStatus: null,
initiationTargetHash: null,
initiationTargetName: null,
};
},
computed: {
@@ -2202,6 +2211,7 @@ export default {
this.activeCall = newCall;
this.initiationStatus = response.data.initiation_status;
this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name;
if (this.activeCall?.is_voicemail) {
this.wasVoicemail = true;
@@ -2862,6 +2872,9 @@ export default {
// Provide immediate feedback
this.destinationHash = hashToCall;
const targetContact = this.contacts.find((c) => c.remote_identity_hash === hashToCall);
this.initiationTargetHash = hashToCall;
this.initiationTargetName = targetContact ? targetContact.name : null;
this.activeTab = "phone";
this.initiationStatus = "Initiating...";
this.isCallEnded = false;

View File

@@ -2,7 +2,8 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="flex-1 overflow-y-auto w-full">
<div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
@@ -15,12 +16,15 @@
<!-- Add New Rule -->
<div class="glass-card space-y-4">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.add_rule") }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("forwarder.add_rule") }}
</div>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
$t("forwarder.name")
}}</label>
<label
class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>{{ $t("forwarder.name") }}</label
>
<input
v-model="newRule.name"
type="text"
@@ -29,9 +33,10 @@
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
$t("forwarder.forward_to_hash")
}}</label>
<label
class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>{{ $t("forwarder.forward_to_hash") }}</label
>
<input
v-model="newRule.forward_to_hash"
type="text"
@@ -40,9 +45,10 @@
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
$t("forwarder.source_filter")
}}</label>
<label
class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>{{ $t("forwarder.source_filter") }}</label
>
<input
v-model="newRule.source_filter_hash"
type="text"
@@ -67,10 +73,17 @@
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("forwarder.active_rules") }}
</div>
<div v-if="rules.length === 0" class="glass-card text-center py-12 text-gray-500 dark:text-zinc-400">
<div
v-if="rules.length === 0"
class="glass-card text-center py-12 text-gray-500 dark:text-zinc-400"
>
{{ $t("forwarder.no_rules") }}
</div>
<div v-for="rule in rules" :key="rule.id" class="glass-card flex items-center justify-between gap-4">
<div
v-for="rule in rules"
:key="rule.id"
class="glass-card flex items-center justify-between gap-4"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div
@@ -90,7 +103,10 @@
</div>
<div class="space-y-1">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="arrow-right" class="w-4 h-4 text-blue-500 shrink-0" />
<MaterialDesignIcon
icon-name="arrow-right"
class="w-4 h-4 text-blue-500 shrink-0"
/>
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ $t("forwarder.forwarding_to", { hash: rule.forward_to_hash }) }}
</span>
@@ -130,6 +146,7 @@
</div>
</div>
</div>
</div>
</template>
<script>

View File

@@ -141,10 +141,7 @@
</select>
<FormSubLabel>
Need help?
<a
class="text-blue-500 underline"
href="https://reticulum.network/manual/interfaces.html"
target="_blank"
<a class="text-blue-500 underline" href="/reticulum-docs/interfaces.html" target="_blank"
>Reticulum Docs: Configuring Interfaces</a
>
</FormSubLabel>
@@ -982,7 +979,7 @@
This setting requires Transport Mode to be enabled.
<a
class="text-blue-500 underline"
href="https://reticulum.network/manual/interfaces.html#interface-modes"
href="/reticulum-docs/interfaces.html#interface-modes"
target="_blank"
>Reticulum Docs: Interface Modes</a
>

View File

@@ -2,7 +2,8 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
<div class="flex-1 overflow-y-auto w-full">
<div class="p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
<div
v-if="showRestartReminder"
class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
@@ -34,7 +35,9 @@
<div class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t("interfaces.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("interfaces.description") }}</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("interfaces.description") }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
@@ -63,8 +66,7 @@
/>
{{ reloadingRns ? $t("app.reloading_rns") : $t("app.reload_rns") }}
</button>
-->
</div>
--></div>
</div>
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
@@ -101,7 +103,9 @@
<div class="w-full sm:w-60">
<select v-model="typeFilter" class="input-field">
<option value="all">{{ $t("interfaces.all_types") }}</option>
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
{{ type }}
</option>
</select>
</div>
</div>
@@ -131,6 +135,7 @@
</div>
</div>
</div>
</div>
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed" />
</template>

View File

@@ -76,14 +76,16 @@
<!-- map container -->
<div class="relative flex-1 min-h-0">
<!-- drawing toolbar -->
<div class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu">
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu w-max max-w-[98vw]"
>
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-0.5 sm:p-1 gap-0 sm:gap-0.5 border-0"
>
<button
v-for="tool in drawingTools"
:key="tool.type"
class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
@@ -92,11 +94,11 @@
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
>
<v-icon :icon="'mdi-' + tool.icon" size="20"></v-icon>
<v-icon :icon="'mdi-' + tool.icon" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
@@ -105,37 +107,37 @@
:title="$t('map.tool_measure')"
@click="toggleMeasure"
>
<v-icon icon="mdi-ruler" size="20"></v-icon>
<v-icon icon="mdi-ruler" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
class="p-1.5 sm:p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@click="clearDrawings"
>
<v-icon icon="mdi-trash-can-outline" size="20"></v-icon>
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.save_drawing')"
@click="showSaveDrawingModal = true"
>
<v-icon icon="mdi-content-save-outline" size="20"></v-icon>
<v-icon icon="mdi-content-save-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.load_drawing')"
@click="openLoadDrawingModal"
>
<v-icon icon="mdi-folder-open-outline" size="20"></v-icon>
<v-icon icon="mdi-folder-open-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
class="p-1.5 sm:p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.go_to_my_location')"
@click="goToMyLocation"
>
<v-icon icon="mdi-crosshairs-gps" size="20"></v-icon>
<v-icon icon="mdi-crosshairs-gps" size="18" class="sm:!size-5"></v-icon>
</button>
</div>
</div>

View File

@@ -954,7 +954,7 @@
</div>
<!-- compose message input -->
<div class="w-full">
<div class="w-full relative">
<input
id="compose-input"
ref="compose-input"
@@ -964,7 +964,53 @@
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
placeholder="Enter LXMF address..."
@keydown.enter.exact.prevent="onComposeEnterPressed"
@keydown.up.prevent="handleComposeInputUp"
@keydown.down.prevent="handleComposeInputDown"
@focus="isComposeInputFocused = true"
@blur="onComposeInputBlur"
/>
<!-- Suggestions Dropdown -->
<div
v-if="isComposeInputFocused && composeSuggestions.length > 0"
class="absolute z-50 left-0 right-0 bottom-full mb-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
>
<div
v-for="(suggestion, index) in composeSuggestions"
:key="suggestion.hash"
class="px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors"
:class="[
index === selectedComposeSuggestionIndex
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
]"
@mousedown.prevent="selectComposeSuggestion(suggestion)"
>
<div
class="shrink-0 size-8 rounded-full flex items-center justify-center text-xs"
:class="
suggestion.type === 'contact'
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
: 'bg-gray-100 dark:bg-zinc-800 text-gray-500'
"
>
<MaterialDesignIcon :icon-name="suggestion.icon" class="size-4" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold truncate">
{{ suggestion.name }}
</div>
<div class="text-[10px] font-mono opacity-50 truncate">
{{ formatDestinationHash(suggestion.hash) }}
</div>
</div>
<div
v-if="suggestion.type === 'contact'"
class="text-[10px] uppercase font-bold tracking-widest opacity-30"
>
Contact
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1092,6 +1138,8 @@ export default {
isSendingMessage: false,
autoScrollOnNewMessage: true,
composeAddress: "",
isComposeInputFocused: false,
selectedComposeSuggestionIndex: -1,
isShareContactModalOpen: false,
contacts: [],
@@ -1146,6 +1194,48 @@ export default {
latestConversations() {
return this.conversations.slice(0, 4);
},
composeSuggestions() {
if (!this.isComposeInputFocused) return [];
const search = this.composeAddress.toLowerCase().trim();
const suggestions = [];
const seenHashes = new Set();
// 1. Check contacts
this.contacts.forEach((c) => {
const hash = c.remote_identity_hash;
if (!seenHashes.has(hash)) {
if (!search || c.name.toLowerCase().includes(search) || hash.toLowerCase().includes(search)) {
suggestions.push({
name: c.name,
hash: hash,
type: "contact",
icon: "account",
});
seenHashes.add(hash);
}
}
});
// 2. Check recent conversations
this.conversations.forEach((c) => {
const hash = c.destination_hash;
if (!seenHashes.has(hash)) {
const name = c.custom_display_name ?? c.display_name;
if (!search || name.toLowerCase().includes(search) || hash.toLowerCase().includes(search)) {
suggestions.push({
name: name,
hash: hash,
type: "recent",
icon: "history",
});
seenHashes.add(hash);
}
}
});
return suggestions.slice(0, 10);
},
canSendMessage() {
// can send if message text is present
const messageText = this.newMessageText.trim();
@@ -1249,8 +1339,19 @@ export default {
// check translator
this.checkTranslator();
// fetch contacts for suggestions
this.fetchContacts();
},
methods: {
async fetchContacts() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts");
this.contacts = response.data;
} catch (e) {
console.log("Failed to fetch contacts:", e);
}
},
async checkTranslator() {
if (!this.config?.translator_enabled) {
this.hasTranslator = false;
@@ -1575,7 +1676,46 @@ export default {
await this.handleComposeAddress(destinationHash);
},
onComposeEnterPressed() {
if (
this.selectedComposeSuggestionIndex >= 0 &&
this.selectedComposeSuggestionIndex < this.composeSuggestions.length
) {
const suggestion = this.composeSuggestions[this.selectedComposeSuggestionIndex];
this.selectComposeSuggestion(suggestion);
} else {
this.onComposeSubmit();
}
},
handleComposeInputUp() {
if (this.composeSuggestions.length > 0) {
if (this.selectedComposeSuggestionIndex > 0) {
this.selectedComposeSuggestionIndex--;
} else {
this.selectedComposeSuggestionIndex = this.composeSuggestions.length - 1;
}
}
},
handleComposeInputDown() {
if (this.composeSuggestions.length > 0) {
if (this.selectedComposeSuggestionIndex < this.composeSuggestions.length - 1) {
this.selectedComposeSuggestionIndex++;
} else {
this.selectedComposeSuggestionIndex = 0;
}
}
},
selectComposeSuggestion(suggestion) {
this.composeAddress = suggestion.hash;
this.isComposeInputFocused = false;
this.selectedComposeSuggestionIndex = -1;
this.onComposeSubmit();
},
onComposeInputBlur() {
// Delay blur to allow mousedown on suggestions
setTimeout(() => {
this.isComposeInputFocused = false;
this.selectedComposeSuggestionIndex = -1;
}, 200);
},
async handleComposeAddress(destinationHash) {
if (destinationHash.startsWith("lxmf@")) {

View File

@@ -8,11 +8,21 @@
<div class="bg-teal-100 dark:bg-teal-900/30 p-1.5 rounded-xl shrink-0">
<MaterialDesignIcon icon-name="code-tags" class="size-5 text-teal-600 dark:text-teal-400" />
</div>
<div class="flex flex-col">
<h1
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider hidden sm:block truncate"
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider hidden sm:block truncate leading-tight"
>
{{ $t("tools.micron_editor.title") }}
</h1>
<a
href="https://github.com/RFnexus"
target="_blank"
rel="noopener noreferrer"
class="text-[10px] text-gray-500 hover:text-teal-500 transition-colors hidden sm:block leading-tight"
>
{{ $t("tools.micron_editor.parser_by") }} RFnexus
</a>
</div>
</div>
<div class="flex items-center gap-2">
<button
@@ -198,17 +208,18 @@ export default {
name: this.$t("tools.micron_editor.main_tab"),
content: oldContent,
},
this.createGuideTab(Date.now() + 1),
];
localStorage.removeItem(this.storageKey);
await micronStorage.saveTabs(this.tabs);
} else {
this.tabs = [this.createDefaultTab()];
this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
await micronStorage.saveTabs(this.tabs);
}
}
} catch (error) {
console.warn("Failed to load content from IndexedDB:", error);
this.tabs = [this.createDefaultTab()];
this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
}
this.activeTabIndex = 0;
},
@@ -219,6 +230,13 @@ export default {
content: this.getDefaultContent(),
};
},
createGuideTab(id = Date.now()) {
return {
id: id,
name: this.$t("tools.micron_editor.guide_tab"),
content: this.getGuideContent(),
};
},
addTab() {
const newTab = {
id: Date.now(),
@@ -259,7 +277,7 @@ export default {
async resetAll() {
if (confirm(this.$t("tools.micron_editor.confirm_reset"))) {
await micronStorage.clearAll();
this.tabs = [this.createDefaultTab()];
this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
this.activeTabIndex = 0;
this.renderActiveTab();
await this.saveContent();
@@ -319,6 +337,393 @@ Use \\${b}= to start/end literal blocks that won't be interpreted.
${b}=
This is a literal block
${b}=
`;
},
getGuideContent() {
const b = "`";
return `-∿
<
${b}c${b}!Hello!${b}! This is output from ${b}*micron${b}*
Micron generates formatted text for your terminal
${b}a
-∿
<
Nomad Network supports a simple and functional markup language called ${b}*micron${b}*. If you are familiar with ${b}*markdown${b}* or ${b}*HTML${b}*, you will feel right at home writing pages with micron.
With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals.
>>Recommendations and Requirements
While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal ${b}*must${b}* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better.
Formatting such as ${b}_underline${b}_, ${b}!bold${b}! or ${b}*italics${b}* will be displayed if your terminal supports it.
If you are having trouble getting micron output to display correctly, try using ${b}*gnome-terminal${b}* or ${b}*alacritty${b}*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly.
>>>Encoding
All micron sources are intepreted as UTF-8, and micron assumes it can output UTF-8 characters to the terminal. If your terminal does not support UTF-8, output will be faulty.
>>>Colors
Shading and coloring text and backgrounds is integral to micron output, and while micron will attempt to gracefully degrade output even to 1-bit terminals, you will get the best output with terminals supporting at least 256 colors. True-color support is recommended.
>>>Terminal Font
While any unicode capable font can be used with micron, it's highly recommended to use a ${b}*"Nerd Font"${b}* (see https://www.nerdfonts.com/), which will add a lot of extra glyphs and icons to your output.
> A Few Demo Outputs
${b}F222${b}Bddd
${b}cWith micron, you can control layout and presentation
${b}a
${b}${b}
${b}B33f
You can change background ...
${b}${b}
${b}B393
${b}r${b}F320... and foreground colors${b}f
${b}a
${b}b
If you want to make a break, horizontal dividers can be inserted. They can be plain, like the one below this text, or you can style them with unicode characters and glyphs, like the wavy divider in the beginning of this document.
-
${b}cText can be ${b}_underlined${b}_, ${b}!bold${b}! or ${b}*italic${b}*.
You can also ${b}_${b}*${b}!B5d5${b}F222combine${b}f${b}b${b}_ ${b}_${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}${b} for some fabulous effects.
${b}a
>>>Sections and Headings
You can define an arbitrary number of sections and sub sections, each with their own named headings. Text inside sections will be automatically indented.
-
If you place a divider inside a section, it will adhere to the section indents.
>>>>>
If no heading text is defined, the section will appear as a sub-section without a header. This can be useful for creating indented blocks of text, like this one.
>Micron tags
Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\.
In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\${b}\\${b} tag anywhere in the markup, which will immediately remove any formatting previously specified.
>>Alignment
To control text alignment use the tag \\${b}c to center text, \\${b}l to left-align, \\${b}r to right-align, and \\${b}a to return to the default alignment of the document. Alignment tags must appear at the beginning of a line. Here is an example:
${b}Faaa
${b}=
${b}cThis line will be centered.
So will this.
${b}aThe alignment has now been returned to default.
${b}rThis will be aligned to the right
${b}${b}
${b}=
${b}${b}
The above markup produces the following output:
${b}Faaa${b}B333
${b}cThis line will be centered.
So will this.
${b}aThe alignment has now been returned to default.
${b}rThis will be aligned to the right
${b}${b}
>>Formatting
Text can be formatted as ${b}!bold${b}! by using the \\${b}! tag, ${b}_underline${b}_ by using the \\${b}_ tag and ${b}*italic${b}* by using the \\${b}* tag.
Here's an example of formatting text:
${b}Faaa
${b}=
We shall soon see ${b}!bold${b}! paragraphs of text decorated with ${b}_underlines${b}_ and ${b}*italics${b}*. Some even dare ${b}!${b}*${b}_combine${b}${b} them!
${b}=
${b}${b}
The above markup produces the following output:
${b}Faaa${b}B333
We shall soon see ${b}!bold${b}! paragraphs of text decorated with ${b}_underlines${b}_ and ${b}*italics${b}*. Some even dare ${b}!${b}*${b}_combine${b}!${b}*${b}_ them!
${b}${b}
>>Sections
To create sections and subsections, use the > tag. This tag must be placed at the beginning of a line. To specify a sub-section of any level, use any number of > tags. If text is placed after a > tag, it will be used as a heading.
Here is an example of sections:
${b}Faaa
${b}=
>High Level Stuff
This is a section. It contains this text.
>>Another Level
This is a sub section.
>>>Going deeper
A sub sub section. We could continue, but you get the point.
>>>>
Wait! It's worth noting that we can also create sections without headings. They look like this.
${b}=
${b}${b}
The above markup produces the following output:
${b}Faaa${b}B333
>High Level Stuff
This is a section. It contains this text.
>>Another Level
This is a sub section.
>>>Going deeper
A sub sub section. We could continue, but you get the point.
>>>>
Wait! It's worth noting that we can also create sections without headings. They look like this.
${b}${b}
>Colors
Foreground colors can be specified with the \\${b}F tag, followed by three hexadecimal characters. To return to the default foreground color, use the \\${b}f tag. Background color is specified in the same way, but by using the \\${b}B and \\${b}b tags.
Here's a few examples:
${b}Faaa
${b}=
You can use ${b}B5d5${b}F222 color ${b}f${b}b ${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}f for some fabulous effects.
${b}=
${b}${b}
The above markup produces the following output:
${b}Faaa${b}B333
You can use ${b}B5d5${b}F222 color ${b}f${b}B333 ${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}f for some fabulous effects.
${b}${b}
>Links
Links to pages, files or other resources can be created with the \\${b}[ tag, which should always be terminated with a closing ]. You can create links with and without labels, it is up to you to control the formatting of links with other tags. Although not strictly necessary, it is good practice to at least format links with underlining.
Here's a few examples:
${b}Faaa
${b}=
Here is a link without any label: ${b}[72914442a3689add83a09a767963f57c:/page/index.mu]
This is a ${b}[labeled link${b}72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it
Here is ${b}F00a${b}_${b}[a more visible link${b}72914442a3689add83a09a767963f57c:/page/index.mu]${b}_${b}f
${b}=
${b}${b}
The above markup produces the following output:
${b}Faaa${b}B333
Here is a link without any label: ${b}[72914442a3689add83a09a767963f57c:/page/index.mu]
This is a ${b}[labeled link${b}72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it
Here is ${b}F00f${b}_${b}[a more visible link${b}72914442a3689add83a09a767963f57c:/page/index.mu]${b}_${b}f
${b}${b}
When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL.
>Fields & Requests
Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables.
>>Request Links
Links can contain request variables and a list of fields to submit to the node-side application. You can include all fields on the page, only specific ones, and any number of request variables. To simply submit all fields on a page to a specified node-side page, create a link like this:
${b}Faaa
${b}=
${b}[Submit Fields${b}:/page/fields.mu${b}*]
${b}=
${b}${b}
Note the ${b}!*${b}! following the extra ${b}!\\${b}${b}! at the end of the path. This ${b}!*${b}! denotes ${b}*all fields${b}*. You can also specify a list of fields to include:
${b}Faaa
${b}=
${b}[Submit Fields${b}:/page/fields.mu${b}username|auth_token]
${b}=
${b}${b}
If you want to include pre-set variables, you can do it like this:
${b}Faaa
${b}=
${b}[Query the System${b}:/page/fields.mu${b}username|auth_token|action=view|amount=64]
${b}=
${b}${b}
>> Fields
Here's an example of creating a field. We'll create a field named ${b}!user_input${b}! and fill it with the text ${b}!Pre-defined data${b}!. Note that we are using background color tags to make the field more visible to the user:
${b}Faaa
${b}=
A simple input field: ${b}B444${b}<user_input${b}Pre-defined data>${b}b
${b}=
${b}${b}
You must always set a field ${b}*name${b}*, but you can of course omit the pre-defined value of the field:
${b}Faaa
${b}=
An empty input field: ${b}B444${b}<demo_empty${b}>${b}b
${b}=
${b}${b}
You can set the size of the field like this:
${b}Faaa
${b}=
A sized input field: ${b}B444${b}<16|with_size${b}>${b}b
${b}=
${b}${b}
It is possible to mask fields, for example for use with passwords and similar:
${b}Faaa
${b}=
A masked input field: ${b}B444${b}<!|masked_demo${b}hidden text>${b}b
${b}=
${b}${b}
And you can of course control all parameters at the same time:
${b}Faaa
${b}=
Full control: ${b}B444${b}<!32|all_options${b}hidden text>${b}b
${b}=
${b}${b}
Collecting the above markup produces the following output:
${b}Faaa${b}B333
A simple input field: ${b}B444${b}<user_input${b}Pre-defined data>${b}B333
An empty input field: ${b}B444${b}<demo_empty${b}>${b}B333
A sized input field: ${b}B444${b}<16|with_size${b}>${b}B333
A masked input field: ${b}B444${b}<!|masked_demo${b}hidden text>${b}B333
Full control: ${b}B444${b}<!32|all_options${b}hidden text>${b}B333
${b}b
>>> Checkboxes
In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options.
${b}Faaa
${b}=
${b}<?|field_name|value${b}>${b}b Label Text${b}
${b}=
When the checkbox is checked, it's field will be set to the provided value. If there are multiple checkboxes that share the same field name, the checked values will be concatenated when they are sent to the node by a comma.
${b}${b}
${b}B444${b}<?|sign_up|1${b}>${b}b Sign me up${b}
You can also pre-check both checkboxes and radio groups by appending a |* after the field value.
${b}B444${b}<?|checkbox|1|*${b}>${b}b Pre-checked checkbox${b}
>>> Radio groups
Radio groups are another input that lets the user chose from a set of options. Unlike checkboxes, radio buttons with the same field name are mutually exclusive.
Example:
${b}=
${b}B900${b}<^|color|Red${b}>${b}b Red
${b}B090${b}<^|color|Green${b}>${b}b Green
${b}B009${b}<^|color|Blue${b}>${b}b Blue
${b}=
will render:
${b}B900${b}<^|color|Red${b}>${b}b Red
${b}B090${b}<^|color|Green${b}>${b}b Green
${b}B009${b}<^|color|Blue${b}>${b}b Blue
In this example, when the data is submitted, ${b}B444${b} field_color${b}b will be set to whichever value from the list was selected.
${b}${b}
>Comments
You can insert comments that will not be displayed in the output by starting a line with the # character.
Here's an example:
${b}Faaa
${b}=
# This line will not be displayed
This line will
${b}=
${b}${b}
The above markup produces the following output:
${b}Faaa${b}B333
# This line will not be displayed
This line will
${b}${b}
>Literals
To display literal content, for example source-code, or blocks of text that should not be interpreted by micron, you can use literal blocks, specified by the \\${b}= tag. Below is the source code of this entire document, presented as a literal block.
-
${b}=
`;
},

View File

@@ -561,21 +561,19 @@ export default {
// handle success for archived versions first (before path check)
if (nomadnetPageDownload.status === "success" && nomadnetPageDownload.is_archived_version) {
this.nodePagePath = responsePagePath;
this.nodePagePathUrlInput = responsePagePath;
this.isShowingArchivedVersion = true;
this.archivedAt = nomadnetPageDownload.archived_at;
this.nodePageContent = nomadnetPageDownload.page_content;
this.nodePageProgress = 100;
this.isLoadingNodePage = false;
this.currentPageDownloadId = null;
this.renderPageContent(nomadnetPageDownload.page_path, nomadnetPageDownload.page_content);
this.fetchArchives();
return;
}
// ignore response if it's for a different page than currently requested/viewed
if (responsePagePath !== this.nodePagePath) {
console.log(
`ignoring nomadnet page download response for ${responsePagePath} as current page is ${this.nodePagePath}`
);
if (this.nodePagePath && responsePagePath !== this.nodePagePath) {
return;
}
@@ -593,22 +591,18 @@ export default {
const nomadnetPageDownloadCallback =
this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
// handle success
if (nomadnetPageDownload.status === "success") {
if (nomadnetPageDownloadCallback && nomadnetPageDownloadCallback.onSuccessCallback) {
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
this.currentPageDownloadId = null;
return;
}
}
// if no callback found for other statuses, return
if (!nomadnetPageDownloadCallback) {
console.log(
"did not find nomadnet page download callback for key: " +
getNomadnetPageDownloadCallbackKey
);
return;
}
// handle success
if (nomadnetPageDownload.status === "success") {
if (nomadnetPageDownloadCallback.onSuccessCallback) {
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
}
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
this.currentPageDownloadId = null;
return;
}
@@ -703,10 +697,14 @@ export default {
break;
}
case "nomadnet.page.archives": {
const currentRelativePath = this.nodePagePath?.includes(":")
? this.nodePagePath.split(":").slice(1).join(":")
: this.nodePagePath;
if (
this.selectedNode &&
json.destination_hash === this.selectedNode.destination_hash &&
json.page_path === this.nodePagePath
(json.page_path === this.nodePagePath || json.page_path === currentRelativePath)
) {
this.pageArchives = json.archives;
this.isLoadingArchives = false;
@@ -714,10 +712,14 @@ export default {
break;
}
case "nomadnet.page.archive.added": {
const currentRelativePath = this.nodePagePath?.includes(":")
? this.nodePagePath.split(":").slice(1).join(":")
: this.nodePagePath;
if (
this.selectedNode &&
json.destination_hash === this.selectedNode.destination_hash &&
json.page_path === this.nodePagePath
(json.page_path === this.nodePagePath || json.page_path === currentRelativePath)
) {
ToastUtils.success(this.$t("nomadnet.page_archived_successfully"));
this.fetchArchives();
@@ -883,6 +885,7 @@ export default {
this.archivedAt = null;
this.nodePagePath = `${destinationHash}:${pagePath}`;
this.nodePageContent = null;
this.pageArchives = [];
this.nodePageProgress = 0;
// update url bar
@@ -905,8 +908,8 @@ export default {
// if page is cache, we can just return it now
if (cachedNodePageContent != null) {
this.nodePageContent = cachedNodePageContent;
this.renderPageContent(pagePath, cachedNodePageContent);
this.isLoadingNodePage = false;
this.fetchArchives();
return;
}
}
@@ -918,7 +921,6 @@ export default {
(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;
}
@@ -929,17 +931,18 @@ export default {
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
// update page content
this.renderPageContent(pagePath, pageContent);
// update status
this.isLoadingNodePage = false;
// update node path
this.getNodePath(destinationHash);
// check if this page has archives
this.fetchArchives();
},
(failureReason) => {
// do nothing if callback is for a previous request
if (seq !== this.nodePageRequestSequence) {
console.log("ignoring failure callback for previous page request");
return;
}
@@ -953,7 +956,6 @@ export default {
(progress) => {
// do nothing if callback is for a previous request
if (seq !== this.nodePageRequestSequence) {
console.log("ignoring progress callback for previous page request");
return;
}
@@ -1329,11 +1331,15 @@ export default {
fetchArchives() {
if (!this.selectedNode || !this.nodePagePath) return;
this.isLoadingArchives = true;
const parsed = this.parseNomadnetworkUrl(this.nodePagePath);
if (!parsed) return;
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archives.get",
destination_hash: this.selectedNode.destination_hash,
page_path: this.nodePagePath,
page_path: parsed.path,
})
);
},
@@ -1343,6 +1349,13 @@ export default {
this.isShowingArchivedVersion = false;
this.archivedAt = null;
this.nodePageProgress = 0;
const archive = this.pageArchives.find((a) => a.id === archiveId);
if (archive) {
this.nodePagePath = `${archive.destination_hash}:${archive.page_path}`;
this.nodePagePathUrlInput = this.nodePagePath;
}
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archive.load",
@@ -1354,11 +1367,15 @@ export default {
manualArchive() {
if (!this.selectedNode || !this.nodePagePath || !this.nodePageContent) return;
ToastUtils.info(this.$t("nomadnet.archiving_page"));
const parsed = this.parseNomadnetworkUrl(this.nodePagePath);
if (!parsed) return;
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archive.add",
destination_hash: this.selectedNode.destination_hash,
page_path: this.nodePagePath,
page_path: parsed.path,
content: this.nodePageContent,
})
);

View File

@@ -2,7 +2,8 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="overflow-y-auto p-3 md:p-4 max-w-5xl mx-auto w-full">
<div class="flex-1 overflow-y-auto w-full">
<div class="p-3 md:p-4 max-w-5xl mx-auto w-full">
<!-- header -->
<div class="glass-card mb-4">
<div class="flex items-center gap-3">
@@ -217,6 +218,7 @@
</div>
</div>
</div>
</div>
</template>
<script>

View File

@@ -45,17 +45,75 @@
<!-- path table -->
<div v-if="tab === 'table'" class="space-y-4">
<!-- filters -->
<div class="glass-card p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Search Hash or Via..."
class="input-field pl-10"
/>
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-3 top-1/2 -translate-y-1/2 size-5 text-gray-400"
/>
</div>
<select v-model="filterInterface" class="input-field">
<option value="">All Interfaces</option>
<option v-for="iface in interfaces" :key="iface" :value="iface">
{{ iface }}
</option>
</select>
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-gray-500 uppercase min-w-fit">Hops:</span>
<input
v-model.number="filterHops"
type="number"
min="0"
max="128"
placeholder="Any"
class="input-field"
/>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex flex-col items-end">
<span class="text-[10px] font-bold text-gray-400 uppercase">Total</span>
<span class="text-sm font-bold">{{ totalItems }}</span>
</div>
<div class="flex flex-col items-end">
<span class="text-[10px] font-bold text-green-500 uppercase">Responsive</span>
<span class="text-sm font-bold text-green-600 dark:text-green-400">{{
responsiveItems
}}</span>
</div>
<div class="flex flex-col items-end">
<span class="text-[10px] font-bold text-red-500 uppercase">Unresponsive</span>
<span class="text-sm font-bold text-red-600 dark:text-red-400">{{
unresponsiveItems
}}</span>
</div>
</div>
</div>
<div v-if="pathTable.length === 0" class="glass-card p-12 text-center text-gray-500">
No paths currently known.
No paths found matching your criteria.
</div>
<div v-else class="grid gap-4">
<div
v-for="path in pathTable"
:key="path.hash"
class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-l-4"
:class="[
path.state === 2
? 'border-l-green-500'
: path.state === 1
? 'border-l-red-500'
: 'border-l-gray-300 dark:border-l-zinc-700',
]"
>
<div class="min-w-0">
<div class="flex items-center gap-2 mb-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
{{ path.hash }}
</span>
@@ -64,11 +122,28 @@
>
{{ path.hops }} {{ path.hops === 1 ? "hop" : "hops" }}
</span>
<span
class="px-2 py-0.5 text-[10px] font-bold rounded uppercase tracking-wider"
:class="getStateColor(path.state)"
>
{{ getStateText(path.state) }}
</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
via {{ path.via }} on {{ path.interface }}
</div>
<div class="text-[10px] text-gray-400 mt-1">Expires: {{ formatDate(path.expires) }}</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[10px]">
<div class="text-gray-400">
<span class="font-semibold uppercase">Last Updated:</span>
{{ path.timestamp ? formatDate(path.timestamp) : "Unknown" }}
</div>
<div class="text-gray-400">
<span class="font-semibold uppercase">Expires:</span> {{ formatDate(path.expires) }}
</div>
<div v-if="path.announce_hash" class="text-gray-400">
<span class="font-semibold uppercase">Announce Hash:</span> {{ path.announce_hash }}
</div>
</div>
</div>
<button
class="px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 rounded-lg transition-colors border border-red-200 dark:border-red-900/30"
@@ -78,6 +153,39 @@
</button>
</div>
</div>
<!-- pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between glass-card p-4">
<div class="flex items-center gap-2">
<button
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
:disabled="currentPage === 1"
@click="currentPage--"
>
<MaterialDesignIcon icon-name="chevron-left" class="size-5" />
</button>
<span class="text-sm font-medium"> Page {{ currentPage }} of {{ totalPages }} </span>
<button
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
<MaterialDesignIcon icon-name="chevron-right" class="size-5" />
</button>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 uppercase font-semibold">Show:</span>
<select
v-model="itemsPerPage"
class="bg-transparent border-none text-sm font-bold focus:ring-0 cursor-pointer"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="250">250</option>
</select>
</div>
</div>
</div>
<!-- announce rates -->
@@ -208,8 +316,40 @@ export default {
rateTable: [],
requestHash: "",
dropViaHash: "",
// Pagination & Filtering
searchQuery: "",
filterInterface: "",
filterHops: null,
currentPage: 1,
itemsPerPage: 50,
totalItems: 0,
responsiveItems: 0,
unresponsiveItems: 0,
interfaces: [],
};
},
computed: {
totalPages() {
return Math.ceil(this.totalItems / this.itemsPerPage);
},
},
watch: {
searchQuery() {
this.currentPage = 1;
this.refreshTable();
},
filterInterface() {
this.currentPage = 1;
this.refreshTable();
},
filterHops() {
this.currentPage = 1;
this.refreshTable();
},
currentPage() {
this.refreshTable();
},
},
mounted() {
this.refreshAll();
},
@@ -217,12 +357,17 @@ export default {
async refreshAll() {
this.isLoading = true;
try {
const [pathRes, rateRes] = await Promise.all([
window.axios.get("/api/v1/rnpath/table"),
const [pathRes, rateRes, ifaceRes] = await Promise.all([
this.fetchPathTable(),
window.axios.get("/api/v1/rnpath/rates"),
window.axios.get("/api/v1/reticulum/interfaces"),
]);
this.pathTable = pathRes.data.table;
this.pathTable = pathRes.table;
this.totalItems = pathRes.total;
this.responsiveItems = pathRes.responsive;
this.unresponsiveItems = pathRes.unresponsive;
this.rateTable = rateRes.data.rates;
this.interfaces = Object.keys(ifaceRes.data.interfaces);
} catch (e) {
console.error(e);
ToastUtils.error("Failed to fetch path data");
@@ -230,6 +375,41 @@ export default {
this.isLoading = false;
}
},
async refreshTable() {
this.isLoading = true;
try {
const res = await this.fetchPathTable();
this.pathTable = res.table;
this.totalItems = res.total;
this.responsiveItems = res.responsive;
this.unresponsiveItems = res.unresponsive;
} catch (e) {
console.error(e);
} finally {
this.isLoading = false;
}
},
async fetchPathTable() {
const params = {
page: this.currentPage,
limit: this.itemsPerPage,
search: this.searchQuery || undefined,
interface: this.filterInterface || undefined,
hops: this.filterHops !== null ? this.filterHops : undefined,
};
const res = await window.axios.get("/api/v1/rnpath/table", { params });
return res.data;
},
getStateColor(state) {
if (state === 2) return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300";
if (state === 1) return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
},
getStateText(state) {
if (state === 2) return "RESPONSIVE";
if (state === 1) return "UNRESPONSIVE";
return "UNKNOWN";
},
async dropPath(hash) {
if (!(await DialogUtils.confirm(`Are you sure you want to drop the path to ${hash}?`))) {
return;

View File

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,15 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="flex-1 overflow-y-auto w-full">
<div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("tools.power_tools") }}</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ $t("tools.power_tools") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("tools.diagnostics_description") }}
</div>
@@ -43,7 +46,9 @@
</RouterLink>
<RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
<div class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200">
<div
class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200"
>
<MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
</div>
<div class="flex-1">
@@ -192,6 +197,7 @@
</div>
</div>
</div>
</div>
</template>
<script>