feat(docs): update documentation navigation and add bundled documentation hints in interface components

This commit is contained in:
Ivan
2026-04-29 18:27:11 -05:00
parent ce18847058
commit e6144cfd14
6 changed files with 1044 additions and 193 deletions
+45
View File
@@ -1212,6 +1212,24 @@ export default {
}
break;
}
if (json.ingest_type === "docs_view") {
const dq = json.docs_query;
const rel = dq && typeof dq.reticulum === "string" ? dq.reticulum.trim() : "";
if (rel) {
await this.$router.push({
name: "documentation",
query: { reticulum: encodeURIComponent(rel) },
});
} else {
await this.$router.push({ name: "documentation" });
}
if (json.status === "error") {
ToastUtils.error(json.message);
} else if (json.message) {
ToastUtils.info(json.message);
}
break;
}
if (json.status === "success") {
ToastUtils.success(json.message);
} else if (json.status === "error") {
@@ -1766,6 +1784,33 @@ export default {
this.$router.push({ name: "call", query: { tab: "phone" } });
return;
}
try {
const u = new URL(normalizedUrl);
const proto = u.protocol.toLowerCase();
const host = u.hostname.toLowerCase();
if ((proto === "meshchatx:" || proto === "meshchat:") && host === "docs") {
let rel = u.searchParams.get("reticulum") ?? u.searchParams.get("path") ?? "";
rel = String(rel).trim();
if (!rel && u.pathname && u.pathname !== "/") {
try {
rel = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
} catch {
rel = u.pathname.replace(/^\/+/, "");
}
}
if (rel) {
this.$router.push({
name: "documentation",
query: { reticulum: encodeURIComponent(rel) },
});
} else {
this.$router.push({ name: "documentation" });
}
return;
}
} catch {
/* not a valid URL; continue */
}
if (/^(meshchatx|meshchat):\/\/map\b/i.test(normalizedUrl)) {
WebSocketConnection.send(
JSON.stringify({
@@ -280,6 +280,41 @@
<p class="text-gray-600 dark:text-zinc-400 text-sm">
{{ $t("tutorial.bootstrap_desc") }}
</p>
<div class="flex flex-col items-center gap-2 pt-1">
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-2 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
:disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
@click="pickRandomTcpBootstraps"
>
<v-progress-circular
v-if="pickingRandomBootstraps"
indeterminate
size="16"
width="2"
/>
<v-icon v-else icon="mdi-shuffle-variant" size="18" />
{{ $t("tutorial.bootstrap_pick_random_tcp") }}
</button>
<div
v-if="bootstrapSelectedLabels.length > 0"
class="w-full max-w-md rounded-xl border border-gray-200/90 bg-gray-50/80 px-3 py-2 text-left dark:border-zinc-700 dark:bg-zinc-900/50"
>
<div
class="text-[10px] font-bold uppercase tracking-wide text-gray-500 dark:text-zinc-400"
>
{{ $t("tutorial.bootstrap_selected_nodes_heading") }}
</div>
<ul class="mt-1 space-y-0.5 text-xs text-gray-800 dark:text-zinc-200">
<li
v-for="(label, idx) in bootstrapSelectedLabels"
:key="selectedBootstrapKeys[idx]"
>
{{ label }}
</li>
</ul>
</div>
</div>
</div>
<div
@@ -594,7 +629,7 @@
</div>
</div>
<!-- Step 5: Documentation & Tools -->
<!-- Step 5: Learn & Create -->
<div v-else-if="currentStep === 5" key="step5-tools" class="space-y-6">
<div class="text-center space-y-2">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
@@ -605,114 +640,197 @@
</p>
</div>
<div class="grid grid-cols-1 gap-4">
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800"
>
<v-icon icon="mdi-book-open-variant" color="blue" size="32"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.documentation") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mb-2">
{{ $t("tutorial.documentation_desc") }}
</div>
<div class="flex gap-2">
<a
href="/meshchatx-docs/index.html"
target="_blank"
class="px-3 py-1 text-[10px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-xs transition-all inline-block"
>
{{ $t("tutorial.meshchatx_docs") }}
</a>
<a
:href="reticulumBundledDocsUrl"
target="_blank"
class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500 inline-block"
>
{{ $t("tutorial.reticulum_docs") }}
</a>
</div>
</div>
</div>
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800"
>
<v-icon icon="mdi-file-document-edit-outline" color="orange" size="32"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.micron_editor") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mb-2">
{{ $t("tutorial.micron_editor_desc") }}
</div>
<button
type="button"
class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="gotoRoute('micron-editor')"
>
{{ $t("tutorial.open_micron_editor") }}
</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-6">
<div class="flex w-full flex-col gap-4 max-w-xl mx-auto">
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
@click="gotoRoute('nomadnetwork')"
class="flex w-full items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 touch-manipulation"
>
<v-icon icon="mdi-earth" color="purple" size="24"></v-icon>
<v-icon icon="mdi-book-open-variant" color="blue" size="32"></v-icon>
<div class="min-w-0 flex-1">
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.documentation") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mb-2">
{{ $t("tutorial.documentation_desc") }}
</div>
<div class="flex flex-wrap gap-2">
<a
href="/meshchatx-docs/index.html"
target="_blank"
class="px-3 py-1 text-[10px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-xs transition-all inline-block"
>
{{ $t("tutorial.meshchatx_docs") }}
</a>
<a
:href="reticulumBundledDocsUrl"
target="_blank"
class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500 inline-block"
>
{{ $t("tutorial.reticulum_docs") }}
</a>
</div>
</div>
</div>
<div
class="flex w-full items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 touch-manipulation"
>
<v-icon icon="mdi-file-document-edit-outline" color="orange" size="32"></v-icon>
<div class="min-w-0 flex-1">
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.micron_editor") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mb-2">
{{ $t("tutorial.micron_editor_desc") }}
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="gotoRoute('micron-editor')"
>
{{ $t("tutorial.open_micron_editor") }}
</button>
<button
type="button"
class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="gotoRoute('mesh-server')"
>
{{ $t("tutorial.open_mesh_server") }}
</button>
</div>
</div>
</div>
<div
class="flex w-full cursor-pointer items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-colors hover:border-indigo-500 touch-manipulation min-h-[4.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('identities')"
@keydown.enter="gotoRoute('identities')"
>
<v-icon icon="mdi-account-multiple-outline" color="indigo" size="32"></v-icon>
<div class="min-w-0 flex-1">
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.identities_card_title") }}
</div>
<div class="text-sm text-gray-600 dark:text-zinc-400">
{{ $t("tutorial.identities_card_desc") }}
</div>
</div>
</div>
<div
class="flex w-full cursor-pointer items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-colors hover:border-teal-500 touch-manipulation min-h-[4.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('archives')"
@keydown.enter="gotoRoute('archives')"
>
<v-icon icon="mdi-archive-outline" color="teal" size="32"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white text-xs">
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.archiver") }}
</div>
<div class="text-sm text-gray-900 dark:text-white">
{{ $t("tutorial.archiver_desc") }}
</div>
</div>
</div>
</div>
<p
class="text-center text-[11px] font-semibold tracking-wide text-gray-600 dark:text-zinc-400 px-2"
>
{{ $t("tutorial.learn_create_more") }}
</p>
<div class="grid grid-cols-2 gap-2 max-w-xl mx-auto">
<div
class="flex flex-col gap-1.5 rounded-xl border border-gray-100 bg-gray-50 p-2.5 dark:border-zinc-800 dark:bg-zinc-900 cursor-pointer hover:border-blue-500 transition-colors touch-manipulation min-h-[5.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('nomadnetwork')"
@keydown.enter="gotoRoute('nomadnetwork')"
>
<v-icon icon="mdi-earth" color="purple" size="22" class="shrink-0"></v-icon>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white text-[11px] leading-tight">
{{ $t("tutorial.paper_messages") }}
</div>
<div class="text-[10px] text-gray-900 dark:text-white">
<div
class="text-[9px] text-gray-600 dark:text-zinc-400 leading-snug line-clamp-3"
>
{{ $t("tutorial.paper_messages_desc") }}
</div>
</div>
</div>
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
class="flex flex-col gap-1.5 rounded-xl border border-gray-100 bg-gray-50 p-2.5 dark:border-zinc-800 dark:bg-zinc-900 cursor-pointer hover:border-blue-500 transition-colors touch-manipulation min-h-[5.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('messages')"
@keydown.enter="gotoRoute('messages')"
>
<v-icon icon="mdi-message-text-outline" color="green" size="24"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white text-xs">
<v-icon
icon="mdi-message-text-outline"
color="green"
size="22"
class="shrink-0"
></v-icon>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white text-[11px] leading-tight">
{{ $t("tutorial.send_messages") }}
</div>
<div class="text-[10px] text-gray-900 dark:text-white">
<div
class="text-[9px] text-gray-600 dark:text-zinc-400 leading-snug line-clamp-3"
>
{{ $t("tutorial.send_messages_desc") }}
</div>
</div>
</div>
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
class="flex flex-col gap-1.5 rounded-xl border border-gray-100 bg-gray-50 p-2.5 dark:border-zinc-800 dark:bg-zinc-900 cursor-pointer hover:border-blue-500 transition-colors touch-manipulation min-h-[5.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('network-visualiser')"
@keydown.enter="gotoRoute('network-visualiser')"
>
<v-icon icon="mdi-hub" color="teal" size="24"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white text-xs">
<v-icon icon="mdi-hub" color="teal" size="22" class="shrink-0"></v-icon>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white text-[11px] leading-tight">
{{ $t("tutorial.explore_nodes") }}
</div>
<div class="text-[10px] text-gray-900 dark:text-white">
<div
class="text-[9px] text-gray-600 dark:text-zinc-400 leading-snug line-clamp-3"
>
{{ $t("tutorial.explore_nodes_desc") }}
</div>
</div>
</div>
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
class="flex flex-col gap-1.5 rounded-xl border border-gray-100 bg-gray-50 p-2.5 dark:border-zinc-800 dark:bg-zinc-900 cursor-pointer hover:border-blue-500 transition-colors touch-manipulation min-h-[5.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('call')"
@keydown.enter="gotoRoute('call')"
>
<v-icon icon="mdi-phone-in-talk-outline" color="red" size="24"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white text-xs">
<v-icon
icon="mdi-phone-in-talk-outline"
color="red"
size="22"
class="shrink-0"
></v-icon>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white text-[11px] leading-tight">
{{ $t("tutorial.voice_calls") }}
</div>
<div class="text-[10px] text-gray-900 dark:text-white">
<div
class="text-[9px] text-gray-600 dark:text-zinc-400 leading-snug line-clamp-3"
>
{{ $t("tutorial.voice_calls_desc") }}
</div>
</div>
@@ -1063,6 +1181,41 @@
<p class="text-lg text-gray-600 dark:text-zinc-400 max-w-3xl mx-auto">
{{ $t("tutorial.bootstrap_desc_page") }}
</p>
<div class="flex flex-col items-center gap-3 pt-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border-2 border-blue-500/30 bg-blue-500/10 px-5 py-2.5 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
:disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
@click="pickRandomTcpBootstraps"
>
<v-progress-circular
v-if="pickingRandomBootstraps"
indeterminate
size="18"
width="2"
/>
<v-icon v-else icon="mdi-shuffle-variant" size="20" />
{{ $t("tutorial.bootstrap_pick_random_tcp") }}
</button>
<div
v-if="bootstrapSelectedLabels.length > 0"
class="w-full max-w-xl rounded-xl border border-gray-200/90 bg-gray-50/80 px-4 py-3 text-left dark:border-zinc-700 dark:bg-zinc-900/50"
>
<div
class="text-xs font-bold uppercase tracking-wide text-gray-500 dark:text-zinc-400"
>
{{ $t("tutorial.bootstrap_selected_nodes_heading") }}
</div>
<ul class="mt-1.5 space-y-1 text-sm text-gray-800 dark:text-zinc-200">
<li
v-for="(label, idx) in bootstrapSelectedLabels"
:key="selectedBootstrapKeys[idx]"
>
{{ label }}
</li>
</ul>
</div>
</div>
</div>
<div
@@ -1382,7 +1535,7 @@
</div>
</div>
<!-- Step 5: Documentation & Tools -->
<!-- Step 5: Learn & Create -->
<div v-else-if="currentStep === 5" key="page-step5-tools" class="space-y-8 py-10">
<div class="text-center space-y-4">
<h2 class="text-4xl font-black text-gray-900 dark:text-white">
@@ -1393,116 +1546,243 @@
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div
class="flex flex-col items-center gap-6 p-8 rounded-4xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800"
>
<v-icon icon="mdi-book-open-variant" color="blue" size="64"></v-icon>
<div>
<div class="font-bold text-2xl text-gray-900 dark:text-white mb-2">
{{ $t("tutorial.documentation") }}
<div class="space-y-8 px-2 sm:px-0">
<div class="flex w-full flex-col gap-5 max-w-2xl mx-auto">
<div
class="flex w-full flex-col gap-4 rounded-3xl border border-gray-100 bg-gray-50 p-6 dark:border-zinc-800 dark:bg-zinc-900 sm:p-8 sm:rounded-[2rem] touch-manipulation"
>
<div class="flex gap-4 sm:gap-5">
<v-icon
icon="mdi-book-open-variant"
color="blue"
size="56"
class="shrink-0"
></v-icon>
<div class="min-w-0 flex-1 text-left">
<div
class="text-xl font-bold text-gray-900 dark:text-white sm:text-2xl mb-2"
>
{{ $t("tutorial.documentation") }}
</div>
<p class="text-gray-700 dark:text-zinc-300 mb-6 text-base">
{{ $t("tutorial.documentation_desc_page") }}
</p>
<div class="flex flex-col gap-3">
<a
href="/meshchatx-docs/index.html"
target="_blank"
class="flex min-h-12 items-center justify-center rounded-xl bg-blue-600 px-4 py-3 text-base font-semibold text-white shadow-xs transition-all hover:bg-blue-500"
>
{{ $t("tutorial.read_meshchatx_docs") }}
</a>
<a
:href="reticulumBundledDocsUrl"
target="_blank"
class="flex min-h-12 items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-3 text-base font-semibold text-gray-700 shadow-xs transition-all hover:border-blue-400 hover:bg-gray-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:border-blue-500 dark:hover:bg-zinc-700"
>
{{ $t("tutorial.reticulum_manual") }}
</a>
</div>
</div>
</div>
<p class="text-gray-900 dark:text-white mb-6">
{{ $t("tutorial.documentation_desc_page") }}
</p>
<div class="flex flex-col gap-3">
<a
href="/meshchatx-docs/index.html"
target="_blank"
class="h-12 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-xs transition-all inline-flex items-center justify-center px-6"
</div>
<div
class="flex w-full flex-col gap-4 rounded-3xl border border-gray-100 bg-gray-50 p-6 dark:border-zinc-800 dark:bg-zinc-900 sm:p-8 sm:rounded-[2rem] touch-manipulation"
>
<div class="flex gap-4 sm:gap-5">
<v-icon
icon="mdi-file-document-edit-outline"
color="orange"
size="56"
class="shrink-0"
></v-icon>
<div class="min-w-0 flex-1 text-left">
<div
class="text-xl font-bold text-gray-900 dark:text-white sm:text-2xl mb-2"
>
{{ $t("tutorial.micron_editor") }}
</div>
<p class="text-gray-700 dark:text-zinc-300 mb-6 text-base">
{{ $t("tutorial.micron_editor_desc_page") }}
</p>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 sm:flex-row">
<button
type="button"
class="flex min-h-12 flex-1 items-center justify-center rounded-xl bg-orange-600 px-4 py-3 text-base font-semibold text-white transition-all hover:bg-orange-500"
@click="gotoRoute('micron-editor')"
>
{{ $t("tutorial.open_micron_editor") }}
</button>
<button
type="button"
class="flex min-h-12 flex-1 items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-3 text-base font-semibold text-gray-700 transition-all hover:bg-gray-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
@click="gotoRoute('mesh-server')"
>
{{ $t("tutorial.open_mesh_server") }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="flex w-full cursor-pointer flex-col gap-4 rounded-3xl border border-gray-100 bg-gray-50 p-6 transition-colors hover:border-indigo-500 dark:border-zinc-800 dark:bg-zinc-900 sm:p-8 sm:rounded-[2rem] touch-manipulation"
role="button"
tabindex="0"
@click="gotoRoute('identities')"
@keydown.enter="gotoRoute('identities')"
>
<div class="flex gap-4 sm:gap-5">
<v-icon
icon="mdi-account-multiple-outline"
color="indigo"
size="56"
class="shrink-0"
></v-icon>
<div class="min-w-0 flex-1 text-left">
<div
class="text-xl font-bold text-gray-900 dark:text-white sm:text-2xl mb-2"
>
{{ $t("tutorial.identities_card_title") }}
</div>
<p class="text-gray-700 dark:text-zinc-300 text-base">
{{ $t("tutorial.identities_card_desc_page") }}
</p>
</div>
</div>
</div>
<div
class="flex w-full cursor-pointer flex-col gap-4 rounded-3xl border border-gray-100 bg-gray-50 p-6 transition-colors hover:border-teal-500 dark:border-zinc-800 dark:bg-zinc-900 sm:p-8 sm:rounded-[2rem] touch-manipulation min-h-[5rem]"
role="button"
tabindex="0"
@click="gotoRoute('archives')"
@keydown.enter="gotoRoute('archives')"
>
<div class="flex gap-4 sm:gap-5">
<v-icon
icon="mdi-archive-outline"
color="teal"
size="56"
class="shrink-0"
></v-icon>
<div class="min-w-0 flex-1 text-left">
<div
class="text-xl font-bold text-gray-900 dark:text-white sm:text-2xl mb-2"
>
{{ $t("tutorial.archiver") }}
</div>
<p class="text-gray-700 dark:text-zinc-300 text-base">
{{ $t("tutorial.archiver_desc_page") }}
</p>
</div>
</div>
</div>
</div>
<p class="text-center text-sm font-semibold text-gray-600 dark:text-zinc-400 px-2">
{{ $t("tutorial.learn_create_more") }}
</p>
<div class="grid grid-cols-2 gap-3 max-w-2xl mx-auto px-1 sm:px-0">
<div
class="flex cursor-pointer flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 p-3 dark:border-zinc-800 dark:bg-zinc-900 transition-colors hover:border-blue-500 touch-manipulation min-h-[6.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('nomadnetwork')"
@keydown.enter="gotoRoute('nomadnetwork')"
>
<v-icon icon="mdi-earth" color="purple" size="28" class="shrink-0"></v-icon>
<div class="min-w-0">
<div
class="font-bold text-gray-900 dark:text-white text-xs sm:text-sm leading-tight"
>
{{ $t("tutorial.read_meshchatx_docs") }}
</a>
<a
:href="reticulumBundledDocsUrl"
target="_blank"
class="h-12 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500 inline-flex items-center justify-center px-6"
{{ $t("tutorial.paper_messages") }}
</div>
<div
class="text-[10px] sm:text-xs text-gray-600 dark:text-zinc-400 mt-1 leading-snug line-clamp-4"
>
{{ $t("tutorial.reticulum_manual") }}
</a>
{{ $t("tutorial.paper_messages_desc") }}
</div>
</div>
</div>
</div>
<div
class="flex flex-col items-center gap-6 p-8 rounded-4xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800"
>
<v-icon icon="mdi-file-document-edit-outline" color="orange" size="64"></v-icon>
<div>
<div class="font-bold text-2xl text-gray-900 dark:text-white mb-2">
{{ $t("tutorial.micron_editor") }}
</div>
<p class="text-gray-900 dark:text-white mb-6">
{{ $t("tutorial.micron_editor_desc_page") }}
</p>
<button
type="button"
class="w-full h-12 rounded-xl bg-orange-600 hover:bg-orange-500 text-white font-semibold shadow-xs transition-all"
@click="gotoRoute('micron-editor')"
>
{{ $t("tutorial.open_micron_editor") }}
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div
class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
@click="gotoRoute('nomadnetwork')"
>
<v-icon icon="mdi-earth" color="purple" size="40"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.paper_messages") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mt-1">
{{ $t("tutorial.paper_messages_desc") }}
<div
class="flex cursor-pointer flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 p-3 dark:border-zinc-800 dark:bg-zinc-900 transition-colors hover:border-blue-500 touch-manipulation min-h-[6.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('messages')"
@keydown.enter="gotoRoute('messages')"
>
<v-icon
icon="mdi-message-text-outline"
color="green"
size="28"
class="shrink-0"
></v-icon>
<div class="min-w-0">
<div
class="font-bold text-gray-900 dark:text-white text-xs sm:text-sm leading-tight"
>
{{ $t("tutorial.send_messages") }}
</div>
<div
class="text-[10px] sm:text-xs text-gray-600 dark:text-zinc-400 mt-1 leading-snug line-clamp-4"
>
{{ $t("tutorial.send_messages_desc") }}
</div>
</div>
</div>
</div>
<div
class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
@click="gotoRoute('messages')"
>
<v-icon icon="mdi-message-text-outline" color="green" size="40"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.send_messages") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mt-1">
{{ $t("tutorial.send_messages_desc") }}
<div
class="flex cursor-pointer flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 p-3 dark:border-zinc-800 dark:bg-zinc-900 transition-colors hover:border-blue-500 touch-manipulation min-h-[6.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('network-visualiser')"
@keydown.enter="gotoRoute('network-visualiser')"
>
<v-icon icon="mdi-hub" color="teal" size="28" class="shrink-0"></v-icon>
<div class="min-w-0">
<div
class="font-bold text-gray-900 dark:text-white text-xs sm:text-sm leading-tight"
>
{{ $t("tutorial.explore_nodes") }}
</div>
<div
class="text-[10px] sm:text-xs text-gray-600 dark:text-zinc-400 mt-1 leading-snug line-clamp-4"
>
{{ $t("tutorial.explore_nodes_desc") }}
</div>
</div>
</div>
</div>
<div
class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
@click="gotoRoute('network-visualiser')"
>
<v-icon icon="mdi-hub" color="teal" size="40"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.explore_nodes") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mt-1">
{{ $t("tutorial.explore_nodes_desc") }}
</div>
</div>
</div>
<div
class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
@click="gotoRoute('call')"
>
<v-icon icon="mdi-phone-in-talk-outline" color="red" size="40"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.voice_calls") }}
</div>
<div class="text-sm text-gray-900 dark:text-white mt-1">
{{ $t("tutorial.voice_calls_desc") }}
<div
class="flex cursor-pointer flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 p-3 dark:border-zinc-800 dark:bg-zinc-900 transition-colors hover:border-blue-500 touch-manipulation min-h-[6.5rem]"
role="button"
tabindex="0"
@click="gotoRoute('call')"
@keydown.enter="gotoRoute('call')"
>
<v-icon
icon="mdi-phone-in-talk-outline"
color="red"
size="28"
class="shrink-0"
></v-icon>
<div class="min-w-0">
<div
class="font-bold text-gray-900 dark:text-white text-xs sm:text-sm leading-tight"
>
{{ $t("tutorial.voice_calls") }}
</div>
<div
class="text-[10px] sm:text-xs text-gray-600 dark:text-zinc-400 mt-1 leading-snug line-clamp-4"
>
{{ $t("tutorial.voice_calls_desc") }}
</div>
</div>
</div>
</div>
@@ -1632,6 +1912,8 @@ export default {
bootstrapListSearch: "",
bootstrapDiscoveredSectionOpen: true,
bootstrapCommunitySectionOpen: true,
bootstrapAutoPickDone: false,
pickingRandomBootstraps: false,
};
},
computed: {
@@ -1696,6 +1978,22 @@ export default {
reticulumBundledDocsUrl() {
return bundledReticulumDocsUrl(this.$i18n.locale);
},
bootstrapSelectedLabels() {
return this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k)).filter(Boolean);
},
},
watch: {
communityInterfaces() {
this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
},
discoveredInterfaces() {
this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
},
currentStep(val) {
if (val === 3) {
this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
}
},
},
beforeUnmount() {
if (this.onWindowResize) {
@@ -1752,6 +2050,7 @@ export default {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
this.bootstrapAutoPickDone = false;
await this.loadDiscoveryBootstrapDefaults();
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
@@ -1834,6 +2133,9 @@ export default {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
await this.maybeAutoPickBootstrapTcp();
} catch (e) {
console.error("Failed to enable discovery:", e);
ToastUtils.error(this.$t("tutorial.failed_enable_discovery"));
@@ -1879,6 +2181,176 @@ export default {
this.selectedBootstrapKeys.push(key);
}
},
bootstrapDisplayLabelForKey(key) {
if (!key) {
return "";
}
if (key.startsWith("comm:")) {
const name = key.slice(5);
const iface = this.communityInterfaces.find((c) => c.name === name);
return iface?.name || name;
}
if (key.startsWith("disc:")) {
const suffix = key.slice(5);
const iface = this.discoveredInterfaces.find((d) => String(d.discovery_hash || d.name) === suffix);
return iface?.name || suffix;
}
return key;
},
communityBootstrapExcludedFromRandom(iface) {
const name = String(iface.name || "");
const desc = String(iface.description || "");
const host = String(iface.target_host || "").trim();
const hay = `${name} ${desc}`.toLowerCase();
if (hay.includes("yggdrasil")) {
return true;
}
if (/\bygg\b/.test(hay) || hay.includes("-ygg") || hay.includes(" ygg") || hay.includes("(ygg")) {
return true;
}
if (/^(200|201|202|203):[0-9a-f:]+$/i.test(host)) {
return true;
}
return false;
},
pickEligibleCommunityTcpBootstrapForRandom() {
const out = [];
for (const iface of this.communityInterfaces) {
const t = iface.type;
if (t !== "TCPClientInterface" && t !== "BackboneInterface") {
continue;
}
const host = String(iface.target_host || "").trim();
const port = iface.target_port;
if (!host || port === undefined || port === null || port === "") {
continue;
}
if (this.communityBootstrapExcludedFromRandom(iface)) {
continue;
}
out.push({
key: `comm:${iface.name}`,
kind: "community",
iface,
dedupe: `${host.toLowerCase()}:${Number(port)}`,
});
}
return out;
},
pickEligibleTcpBootstrapEntries() {
const out = [];
for (const iface of this.communityInterfaces) {
const t = iface.type;
if (t !== "TCPClientInterface" && t !== "BackboneInterface") {
continue;
}
const host = String(iface.target_host || "").trim();
const port = iface.target_port;
if (!host || port === undefined || port === null || port === "") {
continue;
}
out.push({
key: `comm:${iface.name}`,
kind: "community",
iface,
dedupe: `${host.toLowerCase()}:${Number(port)}`,
});
}
for (const iface of this.discoveredInterfaces) {
const host = String(iface.reachable_on || "").trim();
const port = iface.port;
if (!host || port === undefined || port === null || port === "") {
continue;
}
const typ = iface.type || "";
if (typ && typ !== "BackboneInterface" && typ !== "TCPClientInterface") {
continue;
}
out.push({
key: `disc:${iface.discovery_hash || iface.name}`,
kind: "discovered",
iface,
dedupe: `${host.toLowerCase()}:${Number(port)}`,
});
}
return out;
},
dedupeBootstrapEntries(entries) {
const seen = new Set();
const deduped = [];
for (const e of entries) {
if (seen.has(e.dedupe)) {
continue;
}
seen.add(e.dedupe);
deduped.push(e);
}
return deduped;
},
shuffleArrayInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
},
async pickRandomTcpBootstraps(options = {}) {
const silent = options.silent === true;
const auto = options.auto === true;
if (!silent && !auto) {
this.pickingRandomBootstraps = true;
}
await Promise.resolve();
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== "undefined") {
requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
try {
let entries = this.pickEligibleCommunityTcpBootstrapForRandom();
entries = this.dedupeBootstrapEntries(entries);
if (entries.length === 0) {
if (!silent && !auto) {
ToastUtils.warning(this.$t("tutorial.bootstrap_pick_random_none"));
}
return;
}
this.shuffleArrayInPlace(entries);
const take = Math.min(3, entries.length);
this.selectedBootstrapKeys = entries.slice(0, take).map((e) => e.key);
const labels = this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k));
if (!silent && !auto) {
ToastUtils.success(
this.$t("tutorial.bootstrap_pick_random_done", {
count: take,
names: labels.join(", "),
})
);
}
} finally {
if (!silent && !auto) {
this.pickingRandomBootstraps = false;
}
}
},
async maybeAutoPickBootstrapTcp() {
if (this.bootstrapAutoPickDone) {
return;
}
if (this.currentStep !== 3 || this.connectionMode !== "discovery") {
return;
}
if (this.selectedBootstrapKeys.length > 0) {
return;
}
const entries = this.dedupeBootstrapEntries(this.pickEligibleCommunityTcpBootstrapForRandom());
if (entries.length === 0) {
return;
}
await this.pickRandomTcpBootstraps({ silent: true, auto: true });
this.bootstrapAutoPickDone = true;
},
buildBootstrapPayload(item) {
if (item.kind === "discovered") {
const iface = item.iface;
@@ -587,11 +587,20 @@ export default {
if (!this.status.has_docs) return [];
return this.allLanguages.filter((l) => l.code !== this.currentLang);
},
reticulumDocsQueryParam() {
return this.$route?.query?.reticulum;
},
},
watch: {
reticulumDocsQueryParam() {
this.applyDocumentationRouteQuery();
},
},
mounted() {
this.fetchStatus();
this.fetchMeshChatXDocs();
this.statusInterval = setInterval(this.fetchStatus, 2000);
this.applyDocumentationRouteQuery();
},
beforeUnmount() {
if (this.statusInterval) {
@@ -612,6 +621,9 @@ export default {
} catch (error) {
console.error("Failed to fetch docs status:", error);
}
if (this.reticulumDocsQueryParam) {
this.applyDocumentationRouteQuery();
}
},
dismissError() {
this.status = { ...this.status, last_error: null };
@@ -758,6 +770,28 @@ export default {
this.searchQuery = "";
this.searchResults = [];
},
applyDocumentationRouteQuery() {
const q = this.reticulumDocsQueryParam;
if (q === undefined || q === null || q === "") {
return;
}
const raw = Array.isArray(q) ? q[0] : q;
if (typeof raw !== "string" || !raw.trim()) {
return;
}
let path = raw.trim();
try {
path = decodeURIComponent(path);
} catch {
return;
}
path = path.replace(/^\/?(?:reticulum-docs\/)?/, "");
if (!path) {
return;
}
this.activeTab = "reticulum";
this.selectedReticulumPath = path;
},
navigateTo(path) {
if (path.startsWith("/meshchatx-docs/")) {
this.activeTab = "meshchatx";
@@ -163,6 +163,9 @@
<option value="PipeInterface">Pipe Interface (External)</option>
<option value="RNodeIPInterface">RNode over IP</option>
<option value="BackboneInterface">Backbone (public relay)</option>
<option value="__external__">
Custom / external module (RNS interfacepath)
</option>
</select>
</div>
</div>
@@ -241,9 +244,9 @@
$t("interfaces.discovery_default_bootstrap_only")
}}</FormLabel
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
</p>
<BundledDocsHint
paragraph-class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"
/>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
@@ -312,13 +315,20 @@
/>
</div>
<div>
<FormLabel class="glass-label">Transport identity (hex)</FormLabel>
<FormLabel class="glass-label">{{
$t("interfaces.backbone_transport_identity_label")
}}</FormLabel>
<input
v-model="newInterfaceTransportIdentity"
type="text"
placeholder="32 hex chars from the directory"
:placeholder="
$t('interfaces.backbone_transport_identity_placeholder')
"
class="input-field font-mono text-xs"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ $t("interfaces.backbone_transport_identity_hint") }}
</p>
</div>
<div class="flex items-start gap-2">
<Toggle
@@ -333,9 +343,9 @@
$t("interfaces.discovery_default_bootstrap_only")
}}</FormLabel
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
</p>
<BundledDocsHint
paragraph-class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"
/>
</div>
</div>
</div>
@@ -524,12 +534,30 @@
>
<div
v-if="newInterfaceType === 'RNodeInterface'"
class="flex items-center gap-2 pb-2"
class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:gap-6"
>
<Toggle id="rnode-use-ip" v-model="newInterfaceRNodeUseIP" />
<FormLabel for="rnode-use-ip" class="cursor-pointer mb-0! text-sm"
>Connect over network (IP)</FormLabel
>
<div class="flex items-center gap-2">
<Toggle
id="rnode-use-ip"
:model-value="newInterfaceRNodeUseIP"
@update:model-value="setRNodeTransportIp"
/>
<FormLabel for="rnode-use-ip" class="cursor-pointer mb-0! text-sm"
>Connect over network (IP)</FormLabel
>
</div>
<div class="flex items-center gap-2">
<Toggle
id="rnode-use-ble"
:model-value="newInterfaceRNodeUseBle"
@update:model-value="setRNodeTransportBle"
/>
<FormLabel
for="rnode-use-ble"
class="cursor-pointer mb-0! text-sm"
>{{ $t("interfaces.rnode_ble_toggle") }}</FormLabel
>
</div>
</div>
<div
@@ -555,6 +583,28 @@
/>
</div>
</div>
<div
v-else-if="
newInterfaceType === 'RNodeInterface' && newInterfaceRNodeUseBle
"
class="space-y-2"
>
<FormLabel class="glass-label">{{
$t("interfaces.rnode_ble_peer_label")
}}</FormLabel>
<input
v-model="newInterfaceRNodeBlePeer"
type="text"
:placeholder="$t('interfaces.rnode_ble_peer_placeholder')"
class="input-field font-mono text-sm"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
/>
<p class="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
{{ $t("interfaces.rnode_ble_hint") }}
</p>
</div>
<div v-else>
<FormLabel class="glass-label">Serial Port</FormLabel>
<div class="relative">
@@ -1027,6 +1077,59 @@
/>
</div>
</div>
<!-- LocalInterface: IPC path used internally by RNS; optional external module -->
<div v-if="newInterfaceType === 'LocalInterface'" class="space-y-4">
<div class="text-sm text-gray-800 dark:text-zinc-200 leading-snug">
{{ $t("interfaces.loopback_local_title") }}
</div>
<p class="text-xs text-gray-600 dark:text-zinc-400 leading-relaxed">
{{ $t("interfaces.loopback_local_body") }}
</p>
<BundledDocsHint
hint-i18n-key="interfaces.loopback_local_docs_hint"
link-i18n-key="interfaces.loopback_local_docs_link"
:docs-rel-path="docsReticulumInterfacesOverview"
paragraph-class="text-xs text-gray-500 dark:text-gray-400"
/>
</div>
<!-- External interface module (TypeName.py under Reticulum interfacepath) -->
<div v-if="newInterfaceType === '__external__'" class="space-y-4">
<p class="text-xs text-gray-600 dark:text-zinc-400 leading-relaxed">
{{ $t("interfaces.custom_external_intro") }}
</p>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.custom_external_type_label")
}}</FormLabel>
<input
v-model="customExternalTypeName"
type="text"
class="input-field font-mono text-xs"
:placeholder="$t('interfaces.custom_external_type_placeholder')"
autocomplete="off"
spellcheck="false"
/>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.custom_external_json_label")
}}</FormLabel>
<textarea
v-model="customExternalOptionsJson"
rows="10"
class="input-field font-mono text-xs min-h-[10rem]"
:placeholder="$t('interfaces.custom_external_json_placeholder')"
></textarea>
</div>
<BundledDocsHint
hint-i18n-key="interfaces.custom_external_docs_hint"
link-i18n-key="interfaces.custom_external_docs_link"
:docs-rel-path="docsReticulumInterfacesOverview"
paragraph-class="text-xs text-gray-500 dark:text-gray-400"
/>
</div>
</div>
</div>
</div>
@@ -1232,9 +1335,7 @@
<FormLabel class="glass-label mb-0! text-sm">{{
$t("interfaces.discovery_default_bootstrap_only")
}}</FormLabel>
<p class="text-xs text-gray-400">
{{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
</p>
<BundledDocsHint paragraph-class="text-xs text-gray-400" />
</div>
<Toggle v-model="reticulumDiscovery.default_bootstrap_only" />
</div>
@@ -1566,6 +1667,8 @@ import FormLabel from "../forms/FormLabel.vue";
import Toggle from "../forms/Toggle.vue";
import GlobalState from "../../js/GlobalState";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import BundledDocsHint from "./BundledDocsHint.vue";
import { RETICULUM_MANUAL_INTERFACES_OVERVIEW_REL } from "../../js/reticulumDocsEntryUrl.js";
export default {
name: "AddInterfacePage",
@@ -1574,6 +1677,7 @@ export default {
FormLabel,
ExpandingSection,
Toggle,
BundledDocsHint,
},
data() {
return {
@@ -1582,6 +1686,10 @@ export default {
isSaving: false,
isEditingInterface: false,
customExternalTypeName: "",
customExternalOptionsJson: "{}",
docsReticulumInterfacesOverview: RETICULUM_MANUAL_INTERFACES_OVERVIEW_REL,
config: null,
communityInterfaces: [],
@@ -1673,6 +1781,8 @@ export default {
newInterfacePort: null,
newInterfaceRNodeUseIP: false,
newInterfaceRNodeUseBle: false,
newInterfaceRNodeBlePeer: "",
newInterfaceRNodeIPHost: "localhost",
newInterfaceRNodeIPPort: "7633",
RNodeGHzValue: 0,
@@ -1876,6 +1986,28 @@ export default {
console.log(e);
}
},
effectiveRNodeBlePort() {
let p = (this.newInterfaceRNodeBlePeer || "").trim();
if (!p) {
return "ble://";
}
if (p.toLowerCase().startsWith("ble://")) {
return p;
}
return `ble://${p}`;
},
setRNodeTransportIp(v) {
this.newInterfaceRNodeUseIP = Boolean(v);
if (this.newInterfaceRNodeUseIP) {
this.newInterfaceRNodeUseBle = false;
}
},
setRNodeTransportBle(v) {
this.newInterfaceRNodeUseBle = Boolean(v);
if (this.newInterfaceRNodeUseBle) {
this.newInterfaceRNodeUseIP = false;
}
},
async loadCommunityInterfaces() {
try {
const response = await window.api.get(`/api/v1/community-interfaces`);
@@ -1916,6 +2048,25 @@ export default {
this.newInterfaceName = interfaceName;
this.newInterfaceType = iface.type;
this.customExternalTypeName = "";
this.customExternalOptionsJson = "{}";
if (!this.isDedicatedFormInterfaceType(iface.type)) {
this.newInterfaceType = "__external__";
this.customExternalTypeName = iface.type;
const skip = new Set(["type", "name", "interface_enabled", "enabled"]);
const flat = {};
for (const [k, v] of Object.entries(iface)) {
if (skip.has(k)) {
continue;
}
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
continue;
}
flat[k] = v;
}
this.customExternalOptionsJson = JSON.stringify(flat, null, 2);
}
this.newInterfaceTargetHost = iface.target_host ?? iface.remote ?? null;
this.newInterfaceTargetPort = iface.target_port ?? null;
this.newInterfaceTransportIdentity = iface.transport_identity ?? null;
@@ -1982,7 +2133,12 @@ export default {
this.newInterfacePort = iface.port;
this.newInterfaceRNodeUseIP = false;
if (iface.port && String(iface.port).startsWith("tcp://")) {
this.newInterfaceRNodeUseBle = false;
this.newInterfaceRNodeBlePeer = "";
if (iface.port && String(iface.port).toLowerCase().startsWith("ble://")) {
this.newInterfaceRNodeUseBle = true;
this.newInterfaceRNodeBlePeer = String(iface.port);
} else if (iface.port && String(iface.port).startsWith("tcp://")) {
const addr = String(iface.port).replace("tcp://", "");
const parts = addr.split(":");
this.newInterfaceRNodeIPHost = parts[0] || "localhost";
@@ -2103,7 +2259,12 @@ export default {
if (config.forward_port) this.newInterfaceForwardPort = Number(config.forward_port);
if (config.port) {
this.newInterfacePort = config.port;
if (config.port.startsWith("tcp://")) {
this.newInterfaceRNodeUseBle = false;
this.newInterfaceRNodeUseIP = false;
if (String(config.port).toLowerCase().startsWith("ble://")) {
this.newInterfaceRNodeUseBle = true;
this.newInterfaceRNodeBlePeer = config.port;
} else if (config.port.startsWith("tcp://")) {
const addr = config.port.replace("tcp://", "");
const [host, port] = addr.split(":");
this.newInterfaceRNodeIPHost = host;
@@ -2391,6 +2552,73 @@ export default {
const discoveryEnabled = this.discovery.discoverable === true;
const freqHz = Math.round(this.calculateFrequencyInHz());
if (this.newInterfaceType === "RNodeInterface" && this.newInterfaceRNodeUseBle) {
const raw = (this.newInterfaceRNodeBlePeer || "").trim();
const inner = raw.toLowerCase().startsWith("ble://") ? raw.slice(6).trim() : raw;
if (!inner) {
ToastUtils.warning(this.$t("interfaces.rnode_ble_peer_required"));
return;
}
}
if (this.newInterfaceType === "__external__") {
const typeStr = (this.customExternalTypeName || "").trim();
if (!typeStr) {
ToastUtils.error(this.$t("interfaces.custom_external_type_required"));
return;
}
let extra = {};
try {
extra = JSON.parse((this.customExternalOptionsJson || "").trim() || "{}");
} catch {
ToastUtils.error(this.$t("interfaces.custom_external_json_invalid"));
return;
}
if (extra !== null && typeof extra !== "object") {
ToastUtils.error(this.$t("interfaces.custom_external_json_invalid"));
return;
}
const payload = {
allow_overwriting_interface: this.isEditingInterface,
name: this.newInterfaceName,
type: typeStr,
extra_config: extra,
discoverable: discoveryEnabled ? "yes" : null,
discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
announce_interval: discoveryEnabled
? (this.numOrNull(this.discovery.announce_interval) ?? 360)
: null,
reachable_on: discoveryEnabled ? this.discovery.reachable_on : null,
discovery_stamp_value: discoveryEnabled
? (this.numOrNull(this.discovery.discovery_stamp_value) ?? 14)
: null,
discovery_encrypt: discoveryEnabled ? this.discovery.discovery_encrypt === true : null,
publish_ifac: discoveryEnabled ? this.discovery.publish_ifac === true : null,
latitude: discoveryEnabled ? this.numOrNull(this.discovery.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(this.discovery.longitude) : null,
height: discoveryEnabled ? this.numOrNull(this.discovery.height) : null,
discovery_frequency: discoveryEnabled
? this.numOrNull(this.discovery.discovery_frequency)
: null,
discovery_bandwidth: discoveryEnabled
? this.numOrNull(this.discovery.discovery_bandwidth)
: null,
discovery_modulation: discoveryEnabled
? this.numOrNull(this.discovery.discovery_modulation)
: null,
mode: this.sharedInterfaceSettings.mode || null,
bitrate: this.sharedInterfaceSettings.bitrate,
network_name: this.sharedInterfaceSettings.network_name,
passphrase: this.sharedInterfaceSettings.passphrase,
};
const response = await window.api.post(`/api/v1/reticulum/interfaces/add`, payload);
if (response.data.message) ToastUtils.success(response.data.message);
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add(this.newInterfaceName);
this.$router.push({ name: "interfaces" });
return;
}
const i2pPeers =
this.newInterfaceType === "I2PInterface"
? (this.I2PSettings.newInterfacePeers || []).map((p) => String(p).trim()).filter(Boolean)
@@ -2433,7 +2661,9 @@ export default {
this.newInterfaceType === "I2PInterface" ? this.newInterfaceConnectable === true : null,
port: this.newInterfaceRNodeUseIP
? `tcp://${this.newInterfaceRNodeIPHost}:${this.newInterfaceRNodeIPPort}`
: this.newInterfacePort,
: this.newInterfaceRNodeUseBle
? this.effectiveRNodeBlePort()
: this.newInterfacePort,
frequency: freqHz,
bandwidth: this.newInterfaceBandwidth,
txpower: this.newInterfaceTxpower,
@@ -2555,6 +2785,25 @@ export default {
removeSubInterface(idx) {
this.RNodeMultiInterface.subInterfaces.splice(idx, 1);
},
isDedicatedFormInterfaceType(t) {
const builtin = new Set([
"TCPClientInterface",
"BackboneInterface",
"I2PInterface",
"TCPServerInterface",
"UDPInterface",
"RNodeInterface",
"RNodeIPInterface",
"RNodeMultiInterface",
"SerialInterface",
"KISSInterface",
"AX25KISSInterface",
"PipeInterface",
"AutoInterface",
"LocalInterface",
]);
return builtin.has(t);
},
},
};
</script>
@@ -0,0 +1,47 @@
<!-- SPDX-License-Identifier: 0BSD AND MIT -->
<template>
<p :class="paragraphClass">
<span>{{ $t(hintI18nKey) }}</span>
{{ " " }}
<button
type="button"
class="text-blue-600 dark:text-blue-400 hover:underline font-medium cursor-pointer inline p-0 border-0 bg-transparent text-left align-baseline text-[inherit]"
@click="openDocs"
>
{{ $t(linkI18nKey) }}
</button>
</p>
</template>
<script>
import { RETICULUM_MANUAL_INTERFACES_COMMON_OPTIONS_REL } from "../../js/reticulumDocsEntryUrl.js";
import { openBundledReticulumManualPath } from "../../js/reticulumDocsNavigation.js";
export default {
name: "BundledDocsHint",
props: {
paragraphClass: {
type: String,
default: "text-xs text-gray-500 dark:text-gray-400 mt-0.5",
},
hintI18nKey: {
type: String,
default: "interfaces.discovery_default_bootstrap_only_hint",
},
linkI18nKey: {
type: String,
default: "interfaces.discovery_default_bootstrap_docs_link",
},
docsRelPath: {
type: String,
default: RETICULUM_MANUAL_INTERFACES_COMMON_OPTIONS_REL,
},
},
methods: {
openDocs() {
openBundledReticulumManualPath(this.$router, this.docsRelPath);
},
},
};
</script>
@@ -656,7 +656,9 @@
{{ $t("interfaces.discovery_default_bootstrap_only") }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
<BundledDocsHint
paragraph-class="text-xs text-gray-500 dark:text-gray-400"
/>
</div>
</div>
<Toggle
@@ -723,6 +725,7 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import GlobalState from "../../js/GlobalState";
import Toggle from "../forms/Toggle.vue";
import BundledDocsHint from "./BundledDocsHint.vue";
export default {
name: "InterfacesPage",
@@ -731,6 +734,7 @@ export default {
ImportInterfacesModal,
Interface,
MaterialDesignIcon,
BundledDocsHint,
},
data() {
return {