feat(ui): enhance user experience with new features including QR code display, improved toast messages, and localized strings for various components
Some checks failed
CI / test-backend (push) Successful in 3s
CI / build-frontend (push) Successful in 1m48s
CI / test-backend (pull_request) Successful in 18s
CI / test-lang (push) Successful in 2m5s
Build and Publish Docker Image / build (pull_request) Has been skipped
CI / test-lang (pull_request) Successful in 1m14s
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 9m43s
CI / lint (push) Successful in 9m53s
CI / lint (pull_request) Successful in 9m49s
Build Test / Build and Test (pull_request) Successful in 12m57s
Tests / test (push) Successful in 14m2s
Benchmarks / benchmark (push) Successful in 14m29s
Build and Publish Docker Image / build-dev (pull_request) Successful in 19m25s
Tests / test (pull_request) Failing after 23m6s
Benchmarks / benchmark (pull_request) Successful in 29m13s
Build Test / Build and Test (push) Successful in 45m58s

This commit is contained in:
2026-01-05 19:22:25 -06:00
parent 33cbe07750
commit 7d7cd7d487
41 changed files with 2481 additions and 526 deletions

View File

@@ -133,7 +133,7 @@
]"
>
<div
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur"
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur pt-16 sm:pt-0"
>
<!-- toggle button for desktop -->
<div class="hidden sm:flex justify-end p-2 border-b border-gray-100 dark:border-zinc-800">
@@ -353,8 +353,9 @@
<div class="p-2 dark:border-zinc-900 overflow-hidden text-xs">
<div>{{ $t("app.identity_hash") }}</div>
<div
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono"
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono cursor-pointer"
:title="config.identity_hash"
@click="copyValue(config.identity_hash, $t('app.identity_hash'))"
>
{{ config.identity_hash }}
</div>
@@ -362,11 +363,22 @@
<div class="p-2 dark:border-zinc-900 overflow-hidden text-xs">
<div>{{ $t("app.lxmf_address") }}</div>
<div
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono"
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono cursor-pointer"
:title="config.lxmf_address_hash"
@click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
>
{{ config.lxmf_address_hash }}
</div>
<div class="flex items-center justify-end pt-1">
<button
type="button"
class="p-1 rounded-lg text-gray-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
:title="$t('app.show_qr')"
@click.stop="openLxmfQr"
>
<MaterialDesignIcon icon-name="qrcode" class="size-4" />
</button>
</div>
</div>
</div>
</div>
@@ -461,6 +473,51 @@
<ChangelogModal ref="changelogModal" :app-version="appInfo?.version" />
<TutorialModal ref="tutorialModal" />
<!-- LXMF QR modal -->
<div
v-if="showLxmfQr"
class="fixed inset-0 z-[190] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="showLxmfQr = false"
>
<div class="w-full max-w-sm bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">LXMF Address QR</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
@click="showLxmfQr = false"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-4 space-y-3">
<div class="flex justify-center">
<img
v-if="lxmfQrDataUrl"
:src="lxmfQrDataUrl"
alt="LXMF QR"
class="w-48 h-48 bg-white rounded-xl border border-gray-200 dark:border-zinc-800"
/>
</div>
<div
v-if="config?.lxmf_address_hash"
class="text-xs font-mono text-gray-700 dark:text-zinc-200 text-center break-words"
>
{{ config.lxmf_address_hash }}
</div>
<div class="flex justify-center">
<button
type="button"
class="px-3 py-1.5 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline"
@click="copyValue(config?.lxmf_address_hash, $t('app.lxmf_address'))"
>
{{ $t("common.copy") }}
</button>
</div>
</div>
</div>
</div>
<!-- identity switching overlay -->
<transition name="fade-blur">
<div
@@ -500,6 +557,7 @@ import Toast from "./Toast.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import ToastUtils from "../js/ToastUtils";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
import NotificationBell from "./NotificationBell.vue";
import LanguageSelector from "./LanguageSelector.vue";
import CallOverlay from "./call/CallOverlay.vue";
@@ -554,6 +612,9 @@ export default {
appInfo: null,
hasCheckedForModals: false,
showLxmfQr: false,
lxmfQrDataUrl: null,
activeCall: null,
propagationNodeStatus: null,
isCallEnded: false,
@@ -893,13 +954,32 @@ export default {
try {
await window.axios.get(`/api/v1/announce`);
} catch (e) {
ToastUtils.error("failed to announce");
ToastUtils.error(this.$t("app.failed_announce"));
console.log(e);
}
// fetch config so it updates last announced timestamp
await this.getConfig();
},
async copyValue(value, label) {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
ToastUtils.success(`${label} copied`);
} catch {
ToastUtils.success(value);
}
},
async openLxmfQr() {
if (!this.config?.lxmf_address_hash) return;
try {
const uri = `lxmf://${this.config.lxmf_address_hash}`;
this.lxmfQrDataUrl = await QRCode.toDataURL(uri, { margin: 1, scale: 6 });
this.showLxmfQr = true;
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async updateConfig(config, label = null) {
try {
WebSocketConnection.send(

View File

@@ -287,6 +287,14 @@ export default {
type: "action",
action: "toggle-orbit",
},
{
id: "action-bouncing-balls",
title: "action_bouncing_balls",
description: "action_bouncing_balls_desc",
icon: "bounce",
type: "action",
action: "toggle-bouncing-balls",
},
{
id: "action-getting-started",
title: "action_getting_started",
@@ -457,6 +465,8 @@ export default {
});
} else if (result.action === "toggle-orbit") {
GlobalEmitter.emit("toggle-orbit");
} else if (result.action === "toggle-bouncing-balls") {
GlobalEmitter.emit("toggle-bouncing-balls");
} else if (result.action === "show-tutorial") {
GlobalEmitter.emit("show-tutorial");
} else if (result.action === "show-changelog") {

View File

@@ -3,23 +3,24 @@
<v-card color="warning" class="pa-4">
<v-card-title class="headline text-white">
<v-icon start icon="mdi-alert-decagram" class="mr-2"></v-icon>
Security Integrity Warning
{{ $t("about.security_integrity") }}
</v-card-title>
<v-card-text class="text-white mt-2">
<p v-if="integrity.backend && !integrity.backend.ok">
<strong>Backend Tampering Detected!</strong><br />
The application backend binary (unpacked from ASAR) appears to have been modified or replaced. This
could indicate a malicious actor trying to compromise your mesh communication.
<strong>{{ $t("about.tampering_detected") }}</strong
><br />
{{ $t("about.integrity_backend_error") }}
</p>
<p v-if="integrity.data && !integrity.data.ok" class="mt-2">
<strong>Data Tampering Detected!</strong><br />
Your identities or database files appear to have been modified while the app was closed.
<strong>{{ $t("about.tampering_detected") }}</strong
><br />
{{ $t("about.integrity_data_error") }}
</p>
<v-expansion-panels v-if="issues.length > 0" variant="inset" class="mt-4">
<v-expansion-panel title="Technical Details" bg-color="warning-darken-1">
<v-expansion-panel :title="$t('about.technical_issues')" bg-color="warning-darken-1">
<v-expansion-panel-text>
<ul class="text-caption">
<li v-for="(issue, index) in issues" :key="index">{{ issue }}</li>
@@ -29,21 +30,20 @@
</v-expansion-panels>
<p class="mt-4 text-caption">
Proceed with caution. If you did not manually update or modify these files, your installation may be
compromised.
{{ $t("about.integrity_warning_footer") }}
</p>
</v-card-text>
<v-card-actions>
<v-checkbox
v-model="dontShowAgain"
label="I understand, do not show again for this version"
:label="$t('app.do_not_show_again')"
density="compact"
hide-details
class="text-white"
></v-checkbox>
<v-spacer></v-spacer>
<v-btn variant="text" color="white" @click="close"> Continue Anyway </v-btn>
<v-btn variant="text" color="white" @click="close"> {{ $t("common.continue") }} </v-btn>
<v-btn
v-if="integrity.data && !integrity.data.ok"
variant="flat"
@@ -51,7 +51,7 @@
class="text-warning font-bold"
@click="acknowledgeAndReset"
>
Acknowledge & Reset
{{ $t("common.acknowledge_reset") }}
</v-btn>
</v-card-actions>
</v-card>
@@ -104,10 +104,10 @@ export default {
async acknowledgeAndReset() {
try {
await window.axios.post("/api/v1/app/integrity/acknowledge");
ToastUtils.success("Integrity issues acknowledged and manifest reset");
ToastUtils.success(this.$t("about.integrity_acknowledged_reset"));
this.visible = false;
} catch (e) {
ToastUtils.error("Failed to acknowledge integrity issues");
ToastUtils.error(this.$t("about.failed_acknowledge_integrity"));
console.error(e);
}
},

View File

@@ -29,7 +29,7 @@
<!-- content -->
<div class="flex-1 mr-2 text-sm font-medium text-gray-900 dark:text-zinc-100">
{{ toast.message }}
{{ $t(toast.message) }}
</div>
<!-- close button -->

View File

@@ -1405,12 +1405,12 @@ export default {
autoconnect_discovered_interfaces: 3, // default to 3 slots
};
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
ToastUtils.success("Community discovery enabled");
ToastUtils.success(this.$t("tutorial.discovery_enabled"));
this.discoveryOption = "yes";
this.nextStep();
} catch (e) {
console.error("Failed to enable discovery:", e);
ToastUtils.error("Failed to enable discovery");
ToastUtils.error(this.$t("tutorial.failed_enable_discovery"));
} finally {
this.savingDiscovery = false;
}
@@ -1527,7 +1527,7 @@ export default {
ElectronUtils.relaunch();
} else {
if (this.interfaceAddedViaTutorial) {
ToastUtils.info("Restart the application/container to apply changes.");
ToastUtils.info(this.$t("tutorial.ready_desc"));
}
this.visible = false;
}

View File

@@ -46,9 +46,7 @@
</div>
</div>
<div
class="mt-10 pt-8 border-t border-gray-100 dark:border-zinc-800 flex flex-col md:flex-row md:items-center justify-between gap-6"
>
<div class="mt-10 pt-8 border-t border-gray-100 dark:border-zinc-800 flex flex-col gap-6">
<div class="text-gray-600 dark:text-zinc-400 max-w-xl text-lg leading-relaxed">
A secure, resilient, and beautiful communications platform powered by the
<a
@@ -58,6 +56,81 @@
>Reticulum Network Stack</a
>.
</div>
<!-- Contact Developer Card -->
<div class="glass-card !p-5 space-y-3">
<div class="flex items-center justify-between">
<div
class="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-zinc-500"
>
Contact Developer
</div>
<v-icon
:icon="showContactDev ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="18"
></v-icon>
</div>
<button
class="w-full text-left flex items-center justify-between px-4 py-2 rounded-xl bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/20 transition-all"
@click="showContactDev = !showContactDev"
>
<div class="flex items-center gap-2">
<v-icon icon="mdi-account-card-details" size="18"></v-icon>
<span class="text-xs font-black uppercase tracking-widest">Details</span>
</div>
</button>
<transition name="fade">
<div
v-if="showContactDev"
class="mt-4 p-5 rounded-2xl bg-white/50 dark:bg-zinc-950/50 border border-gray-100 dark:border-zinc-800 space-y-4"
>
<div class="space-y-1">
<div class="text-[9px] font-black text-gray-400 uppercase tracking-widest">
LXMF Address
</div>
<div class="flex items-center gap-2">
<code
class="text-[11px] font-mono bg-zinc-100 dark:bg-zinc-900 px-2 py-1 rounded-lg break-all"
>7cc8d66b4f6a0e0e49d34af7f6077b5a</code
>
<button
class="p-1 hover:text-blue-500 transition"
@click="copyValue('7cc8d66b4f6a0e0e49d34af7f6077b5a', 'LXMF Address')"
>
<v-icon icon="mdi-content-copy" size="14"></v-icon>
</button>
</div>
</div>
<div class="space-y-1">
<div class="text-[9px] font-black text-gray-400 uppercase tracking-widest">
Alternate
</div>
<div class="flex items-center gap-2">
<code
class="text-[11px] font-mono bg-zinc-100 dark:bg-zinc-900 px-2 py-1 rounded-lg break-all"
>43d3309adf27fc446556121b553b56a6</code
>
<button
class="p-1 hover:text-blue-500 transition"
@click="
copyValue('43d3309adf27fc446556121b553b56a6', 'Alternate Address')
"
>
<v-icon icon="mdi-content-copy" size="14"></v-icon>
</button>
</div>
</div>
<div
class="text-xs font-bold text-gray-500 dark:text-white italic bg-blue-500/5 p-3 rounded-xl border border-blue-500/10 flex items-center gap-2"
>
<v-icon icon="mdi-information-outline" size="14" class="text-blue-500"></v-icon>
Send to propagation node if you cant reach me!
</div>
</div>
</transition>
</div>
<div class="flex items-center gap-6 shrink-0">
<div class="text-right">
<div
@@ -869,6 +942,7 @@ export default {
chromeVersion: null,
nodeVersion: null,
showIdentityPaste: false,
showContactDev: false,
};
},
computed: {
@@ -928,23 +1002,23 @@ export default {
}
},
async deleteSnapshot(filename) {
if (!(await DialogUtils.confirm("Are you sure you want to delete this snapshot?"))) return;
if (!(await DialogUtils.confirm(this.$t("about.delete_snapshot_confirm")))) return;
try {
await window.axios.delete(`/api/v1/database/snapshots/${filename}`);
ToastUtils.success("Snapshot deleted");
ToastUtils.success(this.$t("about.snapshot_deleted"));
await this.listSnapshots();
} catch {
ToastUtils.error("Failed to delete snapshot");
ToastUtils.error(this.$t("about.failed_delete_snapshot"));
}
},
async deleteBackup(filename) {
if (!(await DialogUtils.confirm("Are you sure you want to delete this backup?"))) return;
if (!(await DialogUtils.confirm(this.$t("about.delete_backup_confirm")))) return;
try {
await window.axios.delete(`/api/v1/database/backups/${filename}`);
ToastUtils.success("Backup deleted");
ToastUtils.success(this.$t("about.backup_deleted"));
await this.listAutoBackups();
} catch {
ToastUtils.error("Failed to delete backup");
ToastUtils.error(this.$t("about.failed_delete_backup"));
}
},
async nextSnapshots() {
@@ -990,23 +1064,19 @@ export default {
}
},
async restoreFromSnapshot(path) {
if (
!(await DialogUtils.confirm(
"Are you sure you want to restore this snapshot? This will overwrite the current database and require an app relaunch."
))
) {
if (!(await DialogUtils.confirm(this.$t("about.restore_snapshot_confirm")))) {
return;
}
try {
const response = await window.axios.post("/api/v1/database/restore", { path });
if (response.data.status === "success") {
ToastUtils.success("Database restored. Relaunching...");
ToastUtils.success(this.$t("about.database_restored"));
if (this.isElectron) {
setTimeout(() => ElectronUtils.relaunch(), 2000);
}
}
} catch {
ToastUtils.error("Failed to restore snapshot");
ToastUtils.error(this.$t("about.failed_restore_snapshot"));
}
},
async getAppInfo() {
@@ -1026,17 +1096,13 @@ export default {
}
},
async acknowledgeIntegrity() {
if (
await DialogUtils.confirm(
"Are you sure you want to acknowledge these integrity issues? This will update the security manifest to match the current state of your files."
)
) {
if (await DialogUtils.confirm(this.$t("about.integrity_acknowledge_confirm"))) {
try {
await window.axios.post("/api/v1/app/integrity/acknowledge");
ToastUtils.success("Integrity issues acknowledged");
ToastUtils.success(this.$t("about.integrity_acknowledged"));
await this.getAppInfo();
} catch {
ToastUtils.error("Failed to acknowledge integrity issues");
ToastUtils.error(this.$t("about.failed_acknowledge_integrity"));
}
}
},
@@ -1196,7 +1262,7 @@ export default {
if (this.isElectron) {
ElectronUtils.shutdown();
} else {
ToastUtils.success("Shutdown command sent to server.");
ToastUtils.success(this.$t("about.shutdown_sent"));
}
}
},
@@ -1253,7 +1319,7 @@ export default {
link.remove();
window.URL.revokeObjectURL(url);
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
ToastUtils.success("Identity key file exported");
ToastUtils.success(this.$t("about.identity_exported"));
} catch {
this.identityBackupError = "Failed to download identity";
}
@@ -1270,7 +1336,7 @@ export default {
}
await navigator.clipboard.writeText(this.identityBase32);
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
ToastUtils.success("Identity Base32 key copied to clipboard");
ToastUtils.success(this.$t("about.identity_copied"));
} catch {
this.identityBase32Error = "Failed to copy identity";
}

View File

@@ -8,8 +8,8 @@
<MaterialDesignIcon icon-name="block-helper" class="size-6 text-red-600 dark:text-red-400" />
</div>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Banished</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage Banished users and nodes</p>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t("banishment.title") }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("banishment.description") }}</p>
</div>
</div>
@@ -22,13 +22,13 @@
v-model="searchQuery"
type="text"
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Search by hash or display name..."
:placeholder="$t('banishment.search_placeholder')"
@input="onSearchInput"
/>
</div>
<button
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
title="Refresh"
:title="$t('common.refresh')"
@click="loadBlockedDestinations"
>
<MaterialDesignIcon
@@ -43,7 +43,7 @@
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div v-if="isLoading && blockedItems.length === 0" class="flex flex-col items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">Loading banished items...</p>
<p class="text-gray-500 dark:text-gray-400">{{ $t("banishment.loading_items") }}</p>
</div>
<div
@@ -53,13 +53,9 @@
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
<MaterialDesignIcon icon-name="check-circle" class="size-12" />
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No banished items</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{{ $t("banishment.no_items") }}</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{{
searchQuery
? "No banished items match your search."
: "You haven't banished any users or nodes yet."
}}
{{ searchQuery ? $t("nomadnet.no_search_results_peers") : $t("nomadnet.no_announces_yet") }}
</p>
</div>
@@ -85,19 +81,19 @@
class="text-base font-semibold text-gray-900 dark:text-white break-words"
:title="item.display_name"
>
{{ item.display_name || "Unknown" }}
{{ item.display_name || $t("call.unknown") }}
</h4>
<span
v-if="item.is_node"
class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
>
Node
{{ $t("banishment.node") }}
</span>
<span
v-else
class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded"
>
User
{{ $t("banishment.user") }}
</span>
<span
v-if="item.is_rns_blackholed"
@@ -116,7 +112,7 @@
</div>
</div>
<div v-if="item.created_at" class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Banished {{ formatTimeAgo(item.created_at) }}
{{ $t("banishment.banished_at") }} {{ formatTimeAgo(item.created_at) }}
</div>
<div
v-if="item.rns_source"
@@ -137,7 +133,7 @@
@click="onUnblock(item)"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
<span>Lift Banishment</span>
<span>{{ $t("banishment.lift_banishment") }}</span>
</button>
</div>
</div>
@@ -212,7 +208,7 @@ export default {
}
const processItem = async (hash, data = {}) => {
let displayName = "Unknown";
let displayName = this.$t("call.unknown");
let isNode = false;
try {
@@ -226,7 +222,7 @@ export default {
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
const announce = announceResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
displayName = announce.display_name || this.$t("call.unknown");
isNode = announce.aspect === "nomadnetwork.node";
}
} catch {
@@ -261,7 +257,7 @@ export default {
this.reticulumBlackholedItems = rnsItems;
} catch (e) {
console.log(e);
ToastUtils.error("Failed to load banished destinations");
ToastUtils.error(this.$t("banishment.failed_load_banished"));
} finally {
this.isLoading = false;
}
@@ -269,7 +265,7 @@ export default {
async onUnblock(item) {
if (
!(await DialogUtils.confirm(
`Are you sure you want to lift the banishment for ${item.display_name || item.destination_hash}?`
this.$t("banishment.lift_banishment_confirm", { name: item.display_name || item.destination_hash })
))
) {
return;
@@ -278,10 +274,10 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
await this.loadBlockedDestinations();
ToastUtils.success("Banishment lifted successfully");
ToastUtils.success(this.$t("banishment.banishment_lifted"));
} catch (e) {
console.log(e);
ToastUtils.error("Failed to lift banishment");
ToastUtils.error(this.$t("banishment.failed_lift_banishment"));
}
},
onSearchInput() {},

View File

@@ -409,7 +409,7 @@ export default {
try {
await window.axios.get("/api/v1/telephone/answer");
} catch {
ToastUtils.error("Failed to answer call");
ToastUtils.error(this.$t("call.failed_to_answer_call"));
}
},
async hangupCall() {
@@ -417,15 +417,15 @@ export default {
this.$emit("hangup");
await window.axios.get("/api/v1/telephone/hangup");
} catch {
ToastUtils.error("Failed to hangup call");
ToastUtils.error(this.$t("call.failed_to_hangup_call"));
}
},
async sendToVoicemail() {
try {
await window.axios.get("/api/v1/telephone/send-to-voicemail");
ToastUtils.success("Call sent to voicemail");
ToastUtils.success(this.$t("call.call_sent_to_voicemail"));
} catch {
ToastUtils.error("Failed to send call to voicemail");
ToastUtils.error(this.$t("call.failed_to_send_to_voicemail"));
}
},
async toggleMicrophone() {
@@ -449,7 +449,7 @@ export default {
this.isMicMuting = false;
// Revert on error
this.localMicMuted = !this.localMicMuted;
ToastUtils.error("Failed to toggle microphone");
ToastUtils.error(this.$t("call.failed_to_toggle_microphone"));
}
},
async toggleSpeaker() {
@@ -473,7 +473,7 @@ export default {
this.isSpeakerMuting = false;
// Revert on error
this.localSpeakerMuted = !this.localSpeakerMuted;
ToastUtils.error("Failed to toggle speaker");
ToastUtils.error(this.$t("call.failed_to_toggle_speaker"));
}
},
async playLatestVoicemail() {

View File

@@ -2481,7 +2481,7 @@ export default {
this.refreshAudioDevices();
} catch (err) {
console.error("Web audio failed", err);
ToastUtils.error("Web audio not available");
ToastUtils.error(this.$t("call.web_audio_not_available"));
this.stopWebAudio();
}
},
@@ -2596,9 +2596,9 @@ export default {
if (response.data?.config) {
this.config = response.data.config;
}
ToastUtils.success("Settings saved");
ToastUtils.success(this.$t("call.settings_saved"));
} catch {
ToastUtils.error("Failed to save settings");
ToastUtils.error(this.$t("call.failed_to_save_settings"));
}
},
async getAudioProfiles() {
@@ -2641,6 +2641,8 @@ export default {
await this.ensureWebAudio(response.data.web_audio);
}
this.hydrateContactVisuals();
// If call just ended, refresh history and show ended state
if (oldCall != null && this.activeCall == null) {
this.getHistory();
@@ -2706,6 +2708,7 @@ export default {
}
this.hasMoreCallHistory = newItems.length === this.callHistoryLimit;
this.hydrateContactVisuals();
} catch (e) {
console.log(e);
}
@@ -2769,7 +2772,7 @@ export default {
}
ToastUtils.success(value ? "Do Not Disturb enabled" : "Do Not Disturb disabled");
} catch {
ToastUtils.error("Failed to update Do Not Disturb status");
ToastUtils.error(this.$t("call.failed_to_update_dnd"));
}
},
async toggleAllowCallsFromContactsOnly(value) {
@@ -2782,7 +2785,7 @@ export default {
}
ToastUtils.success(value ? "Calls limited to contacts" : "Calls allowed from everyone");
} catch {
ToastUtils.error("Failed to update call settings");
ToastUtils.error(this.$t("call.failed_to_update_call_settings"));
}
},
async toggleCallRecording(value) {
@@ -2795,7 +2798,7 @@ export default {
}
ToastUtils.success(value ? "Call recording enabled" : "Call recording disabled");
} catch {
ToastUtils.error("Failed to update call recording status");
ToastUtils.error(this.$t("call.failed_to_update_recording_status"));
}
},
async clearHistory() {
@@ -2803,10 +2806,10 @@ export default {
try {
await window.axios.delete("/api/v1/telephone/history");
this.callHistory = [];
ToastUtils.success("Call history cleared");
ToastUtils.success(this.$t("call.call_history_cleared"));
} catch (e) {
console.error(e);
ToastUtils.error("Failed to clear call history");
ToastUtils.error(this.$t("call.failed_to_clear_call_history"));
}
},
async blockIdentity(hash) {
@@ -2815,10 +2818,10 @@ export default {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: hash,
});
ToastUtils.success("Identity banished");
ToastUtils.success(this.$t("call.identity_banished"));
this.getHistory();
} catch {
ToastUtils.error("Failed to banish identity");
ToastUtils.error(this.$t("call.failed_to_banish_identity"));
}
},
async getVoicemailStatus() {
@@ -2976,6 +2979,7 @@ export default {
params: { search: this.contactsSearch },
});
this.contacts = response.data.contacts || (Array.isArray(response.data) ? response.data : []);
this.hydrateContactVisuals();
} catch (e) {
console.log(e);
}
@@ -2986,6 +2990,40 @@ export default {
this.getContacts();
}, 300);
},
hydrateContactVisuals() {
const map = {};
this.contacts.forEach((c) => {
if (!c) return;
const image = c.custom_image;
const keys = [c.remote_identity_hash, c.lxmf_address, c.lxst_address].filter(Boolean);
keys.forEach((k) => {
map[k] = image;
});
});
const applyImage = (target) => {
if (!target) return;
const key =
target.remote_identity_hash || target.remote_destination_hash || target.remote_telephony_hash;
if (key && map[key]) {
target.custom_image = map[key];
}
};
applyImage(this.activeCall);
applyImage(this.lastCall);
if (Array.isArray(this.callHistory) && this.callHistory.length > 0) {
this.callHistory = this.callHistory.map((entry) => {
const key =
entry.remote_identity_hash || entry.remote_destination_hash || entry.remote_telephony_hash;
if (key && map[key]) {
return { ...entry, contact_image: map[key] };
}
return entry;
});
}
},
openAddContactModal() {
this.editingContact = null;
this.contactForm = {
@@ -3013,7 +3051,7 @@ export default {
},
async saveContact(contact) {
if (!contact.name || !contact.remote_identity_hash) {
ToastUtils.error("Name and identity hash required");
ToastUtils.error(this.$t("call.name_and_hash_required"));
return;
}
try {
@@ -3023,10 +3061,10 @@ export default {
contact.clear_image = true;
}
await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, contact);
ToastUtils.success("Contact updated");
ToastUtils.success(this.$t("call.contact_updated"));
} else {
await window.axios.post("/api/v1/telephone/contacts", contact);
ToastUtils.success("Contact added");
ToastUtils.success(this.$t("call.contact_added"));
}
this.isContactModalOpen = false;
this.getContacts();
@@ -3038,10 +3076,10 @@ export default {
if (!confirm("Are you sure you want to delete this contact?")) return;
try {
await window.axios.delete(`/api/v1/telephone/contacts/${contactId}`);
ToastUtils.success("Contact deleted");
ToastUtils.success(this.$t("call.contact_deleted"));
this.getContacts();
} catch {
ToastUtils.error("Failed to delete contact");
ToastUtils.error(this.$t("call.failed_to_delete_contact"));
}
},
onContactImageChange(event) {
@@ -3069,17 +3107,17 @@ export default {
async copyHash(hash) {
try {
await navigator.clipboard.writeText(hash);
ToastUtils.success("Hash copied to clipboard");
ToastUtils.success(this.$t("call.hash_copied"));
} catch (e) {
console.error(e);
ToastUtils.error("Failed to copy hash");
ToastUtils.error(this.$t("call.failed_to_copy_hash"));
}
},
async generateGreeting() {
this.isGeneratingGreeting = true;
try {
await window.axios.post("/api/v1/telephone/voicemail/generate-greeting");
ToastUtils.success("Greeting generated successfully");
ToastUtils.success(this.$t("call.greeting_generated_successfully"));
await this.getVoicemailStatus();
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to generate greeting");
@@ -3101,7 +3139,7 @@ export default {
"Content-Type": "multipart/form-data",
},
});
ToastUtils.success("Greeting uploaded successfully");
ToastUtils.success(this.$t("call.greeting_uploaded_successfully"));
await this.getVoicemailStatus();
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to upload greeting");
@@ -3115,10 +3153,10 @@ export default {
try {
await window.axios.delete("/api/v1/telephone/voicemail/greeting");
ToastUtils.success("Greeting deleted");
ToastUtils.success(this.$t("call.greeting_deleted"));
await this.getVoicemailStatus();
} catch {
ToastUtils.error("Failed to delete greeting");
ToastUtils.error(this.$t("call.failed_to_delete_greeting"));
}
},
async startRecordingGreetingMic() {
@@ -3126,16 +3164,16 @@ export default {
await window.axios.post("/api/v1/telephone/voicemail/greeting/record/start");
await this.getVoicemailStatus();
} catch {
ToastUtils.error("Failed to start recording greeting");
ToastUtils.error(this.$t("call.failed_to_start_recording_greeting"));
}
},
async stopRecordingGreetingMic() {
try {
await window.axios.post("/api/v1/telephone/voicemail/greeting/record/stop");
await this.getVoicemailStatus();
ToastUtils.success("Greeting recorded from mic");
ToastUtils.success(this.$t("call.greeting_recorded_from_mic"));
} catch {
ToastUtils.error("Failed to stop recording greeting");
ToastUtils.error(this.$t("call.failed_to_stop_recording_greeting"));
}
},
async playVoicemail(voicemail) {
@@ -3195,9 +3233,9 @@ export default {
try {
await window.axios.delete(`/api/v1/telephone/voicemails/${voicemailId}`);
this.getVoicemails();
ToastUtils.success("Voicemail deleted");
ToastUtils.success(this.$t("call.voicemail_deleted"));
} catch {
ToastUtils.error("Failed to delete voicemail");
ToastUtils.error(this.$t("call.failed_to_delete_voicemail"));
}
},
async getRecordings() {
@@ -3244,7 +3282,7 @@ export default {
await this.audioPlayer.play();
} catch (e) {
console.error("Failed to play recording:", e);
ToastUtils.error("Failed to load recording audio");
ToastUtils.error(this.$t("call.failed_to_load_recording"));
this.playingRecordingId = null;
this.playingSide = null;
}
@@ -3254,9 +3292,9 @@ export default {
try {
await window.axios.delete(`/api/v1/telephone/recordings/${recordingId}`);
this.getRecordings();
ToastUtils.success("Recording deleted");
ToastUtils.success(this.$t("call.recording_deleted"));
} catch {
ToastUtils.error("Failed to delete recording");
ToastUtils.error(this.$t("call.failed_to_delete_recording"));
}
},
async playGreeting() {
@@ -3282,7 +3320,7 @@ export default {
},
async call(identityHash) {
if (!identityHash) {
ToastUtils.error("Enter an identity to call");
ToastUtils.error(this.$t("call.enter_identity_hash_to_call_error"));
return;
}
@@ -3360,7 +3398,7 @@ export default {
try {
await window.axios.get("/api/v1/telephone/answer");
} catch {
ToastUtils.error("Failed to answer call");
ToastUtils.error(this.$t("call.failed_to_answer_call"));
}
},
async hangupCall() {
@@ -3370,22 +3408,22 @@ export default {
}
await window.axios.get("/api/v1/telephone/hangup");
} catch {
ToastUtils.error("Failed to hangup call");
ToastUtils.error(this.$t("call.failed_to_hangup_call"));
}
},
async sendToVoicemail() {
try {
await window.axios.get("/api/v1/telephone/send-to-voicemail");
ToastUtils.success("Call sent to voicemail");
ToastUtils.success(this.$t("call.call_sent_to_voicemail"));
} catch {
ToastUtils.error("Failed to send call to voicemail");
ToastUtils.error(this.$t("call.failed_to_send_to_voicemail"));
}
},
async switchAudioProfile(audioProfileId) {
try {
await window.axios.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
} catch {
ToastUtils.error("Failed to switch audio profile");
ToastUtils.error(this.$t("call.failed_to_switch_audio_profile"));
}
},
async toggleMicrophone() {
@@ -3412,7 +3450,7 @@ export default {
this.isMicMuting = false;
// Revert on error
this.localMicMuted = !this.localMicMuted;
ToastUtils.error("Failed to toggle microphone");
ToastUtils.error(this.$t("call.failed_to_toggle_microphone"));
}
},
async toggleSpeaker() {
@@ -3439,7 +3477,7 @@ export default {
this.isSpeakerMuting = false;
// Revert on error
this.localSpeakerMuted = !this.localSpeakerMuted;
ToastUtils.error("Failed to toggle speaker");
ToastUtils.error(this.$t("call.failed_to_toggle_speaker"));
}
},
},

View File

@@ -217,7 +217,7 @@ export default {
});
} catch (e) {
console.error("Failed to load audio:", e);
ToastUtils.error("Failed to load audio for editing");
ToastUtils.error(this.$t("call.failed_load_audio_edit"));
this.$emit("close");
} finally {
this.loading = false;
@@ -418,12 +418,12 @@ export default {
headers: { "Content-Type": "multipart/form-data" },
});
ToastUtils.success("Ringtone saved successfully");
ToastUtils.success(this.$t("call.ringtone_saved"));
this.$emit("saved");
this.$emit("close");
} catch (e) {
console.error("Failed to save ringtone:", e);
ToastUtils.error("Failed to save edited ringtone");
ToastUtils.error(this.$t("call.failed_save_ringtone"));
} finally {
this.saving = false;
}

View File

@@ -233,7 +233,7 @@ export default {
});
} catch (e) {
console.error("Failed to load audio:", e);
ToastUtils.error("Failed to load audio for editing");
ToastUtils.error(this.$t("call.failed_load_audio_edit"));
this.$emit("close");
} finally {
this.loading = false;
@@ -449,12 +449,12 @@ export default {
// await window.axios.delete(`/api/v1/telephone/ringtones/${this.ringtone.id}`);
}
ToastUtils.success("Ringtone saved successfully");
ToastUtils.success(this.$t("call.ringtone_saved"));
this.$emit("saved");
this.$emit("close");
} catch (e) {
console.error("Failed to save ringtone:", e);
ToastUtils.error("Failed to save edited ringtone");
ToastUtils.error(this.$t("call.failed_save_ringtone"));
} finally {
this.saving = false;
}

View File

@@ -208,7 +208,7 @@ export default {
this.total = response.data.total;
} catch (e) {
console.log("Failed to fetch logs", e);
if (!silent) ToastUtils.error("Failed to fetch logs");
if (!silent) ToastUtils.error(this.$t("debug.failed_fetch_logs"));
} finally {
if (!silent) this.loading = false;
}
@@ -259,9 +259,9 @@ export default {
.join("\n");
try {
await navigator.clipboard.writeText(logText);
ToastUtils.success("Logs on this page copied to clipboard");
ToastUtils.success(this.$t("debug.logs_copied"));
} catch {
ToastUtils.error("Failed to copy logs");
ToastUtils.error(this.$t("debug.failed_copy_logs"));
}
},
},

View File

@@ -691,7 +691,7 @@ export default {
await this.updateDocs();
} catch (error) {
console.error("Failed to add alternate source:", error);
ToastUtils.error("Failed to update documentation sources");
ToastUtils.error(this.$t("docs.failed_update_docs"));
}
},
async switchVersion(version) {
@@ -743,10 +743,10 @@ export default {
navigator.clipboard
.writeText(url)
.then(() => {
ToastUtils.success("Documentation link copied to clipboard");
ToastUtils.success(this.$t("docs.docs_link_copied"));
})
.catch(() => {
ToastUtils.error("Failed to copy link");
ToastUtils.error(this.$t("docs.failed_copy_link"));
});
},
async setLanguage(langCode) {

View File

@@ -152,6 +152,7 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
export default {
name: "ForwarderPage",
@@ -206,8 +207,8 @@ export default {
this.newRule.forward_to_hash = "";
this.newRule.source_filter_hash = "";
},
deleteRule(id) {
if (confirm(this.$t("forwarder.delete_confirm"))) {
async deleteRule(id) {
if (await DialogUtils.confirm(this.$t("forwarder.delete_confirm"))) {
WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.delete",

View File

@@ -1505,7 +1505,7 @@ export default {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch (e) {
ToastUtils.error("Failed to save config!");
ToastUtils.error(this.$t("common.save_failed"));
console.log(e);
}
},
@@ -1560,10 +1560,10 @@ export default {
};
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
ToastUtils.success("Discovery settings saved");
ToastUtils.success(this.$t("interfaces.discovery_settings_saved"));
await this.loadReticulumDiscoveryConfig();
} catch (e) {
ToastUtils.error("Failed to save discovery settings");
ToastUtils.error(this.$t("interfaces.failed_save_discovery"));
console.log(e);
} finally {
this.savingDiscovery = false;
@@ -1594,7 +1594,7 @@ export default {
// find interface, else show error and redirect to interfaces
const iface = interfaces[interfaceName];
if (!iface) {
DialogUtils.alert("The selected interface for editing could not be found.");
DialogUtils.alert(this.$t("interfaces.interface_not_found"));
this.$router.push({
name: "interfaces",
});

View File

@@ -193,7 +193,7 @@ export default {
// ensure there are some interfaces available to import
if (!response.data.interfaces || response.data.interfaces.length === 0) {
this.clearSelectedFile();
DialogUtils.alert("No interfaces were found in the selected configuration file");
DialogUtils.alert(this.$t("interfaces.no_interfaces_found_config"));
return;
}
@@ -204,7 +204,7 @@ export default {
this.selectAllInterfaces();
} catch (e) {
this.clearSelectedFile();
DialogUtils.alert("Failed to parse configuration file");
DialogUtils.alert(this.$t("interfaces.failed_parse_config"));
console.error(e);
}
},
@@ -237,13 +237,13 @@ export default {
async importSelectedInterfaces() {
// ensure user selected a file to import from
if (!this.selectedFile) {
DialogUtils.alert("Please select a configuration file");
DialogUtils.alert(this.$t("interfaces.select_config_file"));
return;
}
// ensure user selected some interfaces
if (this.selectedInterfaces.length === 0) {
DialogUtils.alert("Please select at least one interface to import");
DialogUtils.alert(this.$t("interfaces.select_at_least_one"));
return;
}
@@ -258,11 +258,9 @@ export default {
this.dismiss(true);
// tell user interfaces were imported
DialogUtils.alert(
"Interfaces imported successfully. MeshChat must be restarted for these changes to take effect."
);
DialogUtils.alert(this.$t("interfaces.import_success"));
} catch (e) {
const message = e.response?.data?.message || "Failed to import interfaces";
const message = e.response?.data?.message || this.$t("interfaces.failed_import_all");
DialogUtils.alert(message);
console.error(e);
}

View File

@@ -694,7 +694,7 @@ export default {
});
this.trackInterfaceChange(interfaceName);
} catch (e) {
DialogUtils.alert("failed to enable interface");
DialogUtils.alert(this.$t("interfaces.failed_enable"));
console.log(e);
}
@@ -709,7 +709,7 @@ export default {
});
this.trackInterfaceChange(interfaceName);
} catch (e) {
DialogUtils.alert("failed to disable interface");
DialogUtils.alert(this.$t("interfaces.failed_disable"));
console.log(e);
}
@@ -726,9 +726,7 @@ export default {
},
async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history
if (
!(await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!"))
) {
if (!(await DialogUtils.confirm(this.$t("interfaces.delete_confirm")))) {
return;
}
@@ -739,7 +737,7 @@ export default {
});
this.trackInterfaceChange(interfaceName);
} catch (e) {
DialogUtils.alert("failed to delete interface");
DialogUtils.alert(this.$t("interfaces.failed_delete"));
console.log(e);
}
@@ -754,7 +752,7 @@ export default {
// download file to browser
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
} catch (e) {
DialogUtils.alert("Failed to export interfaces");
DialogUtils.alert(this.$t("interfaces.failed_export_all"));
console.error(e);
}
},
@@ -768,7 +766,7 @@ export default {
// download file to browser
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
} catch (e) {
DialogUtils.alert("Failed to export interface");
DialogUtils.alert(this.$t("interfaces.failed_export_single"));
console.error(e);
}
},
@@ -903,10 +901,10 @@ export default {
};
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
ToastUtils.success("Discovery settings saved");
ToastUtils.success(this.$t("interfaces.discovery_settings_saved"));
await this.loadDiscoveryConfig();
} catch (e) {
ToastUtils.error("Failed to save discovery settings");
ToastUtils.error(this.$t("interfaces.failed_save_discovery"));
console.log(e);
} finally {
this.savingDiscovery = false;
@@ -962,7 +960,7 @@ export default {
GlobalState.modifiedInterfaceNames.clear();
await this.loadInterfaces();
} catch (e) {
ToastUtils.error(e.response?.data?.error || "Failed to reload Reticulum!");
ToastUtils.error(e.response?.data?.error || this.$t("interfaces.failed_reload"));
console.error(e);
} finally {
this.reloadingRns = false;

View File

@@ -10,22 +10,6 @@
</div>
<div class="ml-auto flex items-center space-x-2">
<!-- export tool toggle -->
<button
v-if="!offlineEnabled"
ref="exportButton"
class="p-2 rounded-lg transition-colors"
:class="
isExportMode
? 'bg-blue-500 text-white shadow-sm'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
:title="$t('map.export_area')"
@click="toggleExportMode"
>
<MaterialDesignIcon icon-name="crop-free" class="size-5" />
</button>
<!-- offline/online toggle -->
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
<button
@@ -92,6 +76,7 @@
<button
v-for="tool in drawingTools"
:key="tool.type"
:ref="tool.type === 'Export' ? 'exportToolButton' : null"
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)
@@ -1430,9 +1415,9 @@ export default {
await window.axios.post("/api/v1/map/mbtiles/active", { filename });
await this.checkOfflineMap();
await this.loadMBTilesList();
ToastUtils.success("Map source updated");
ToastUtils.success(this.$t("map.source_updated"));
} catch {
ToastUtils.error("Failed to set active map");
ToastUtils.error(this.$t("map.failed_set_active"));
}
},
async deleteMBTiles(filename) {
@@ -1443,9 +1428,9 @@ export default {
if (this.metadata && this.metadata.path && this.metadata.path.endsWith(filename)) {
await this.checkOfflineMap();
}
ToastUtils.success("File deleted");
ToastUtils.success(this.$t("map.file_deleted"));
} catch {
ToastUtils.error("Failed to delete file");
ToastUtils.error(this.$t("map.failed_delete_file"));
}
},
async saveMBTilesDir() {
@@ -1453,10 +1438,10 @@ export default {
await window.axios.patch("/api/v1/config", {
map_mbtiles_dir: this.mbtilesDir,
});
ToastUtils.success("Storage directory saved");
ToastUtils.success(this.$t("map.storage_saved"));
this.loadMBTilesList();
} catch {
ToastUtils.error("Failed to save directory");
ToastUtils.error(this.$t("map.failed_save_storage"));
}
},
initMap() {
@@ -1937,9 +1922,9 @@ export default {
await window.axios.delete(`/api/v1/map/export/${this.exportId}`);
this.exportStatus = null;
this.exportId = null;
ToastUtils.success("Export cancelled");
ToastUtils.success(this.$t("map.export_cancelled"));
} catch {
ToastUtils.error("Failed to cancel export");
ToastUtils.error(this.$t("map.failed_cancel_export"));
}
},
async startExport() {
@@ -1957,7 +1942,7 @@ export default {
this.selectedBbox = null;
this.pollExportStatus();
} catch {
ToastUtils.error("Failed to start export");
ToastUtils.error(this.$t("map.failed_start_export"));
this.isExporting = false;
}
},
@@ -1991,7 +1976,7 @@ export default {
if (!file) return;
if (!file.name.endsWith(".mbtiles")) {
ToastUtils.error("Please select an .mbtiles file");
ToastUtils.error(this.$t("map.select_mbtiles_error"));
return;
}
@@ -2042,9 +2027,9 @@ export default {
map_default_lon: center[0],
map_default_zoom: zoom,
});
ToastUtils.success("Default view saved");
ToastUtils.success(this.$t("map.view_saved"));
} catch {
ToastUtils.error("Failed to save default view");
ToastUtils.error(this.$t("map.failed_save_view"));
}
},
async clearCache() {
@@ -2052,7 +2037,7 @@ export default {
await TileCache.clear();
ToastUtils.success(this.$t("map.cache_cleared"));
} catch {
ToastUtils.error("Failed to clear cache");
ToastUtils.error(this.$t("map.failed_clear_cache"));
}
},
async saveTileServerUrl() {
@@ -2064,7 +2049,7 @@ export default {
ToastUtils.success(this.$t("map.tile_server_saved"));
await this.saveMapState();
} catch {
ToastUtils.error("Failed to save tile server URL");
ToastUtils.error(this.$t("map.failed_save_tile_server"));
}
},
setTileServer(type) {
@@ -2080,7 +2065,7 @@ export default {
});
ToastUtils.success(this.$t("map.nominatim_api_saved"));
} catch {
ToastUtils.error("Failed to save Nominatim API URL");
ToastUtils.error(this.$t("map.failed_save_nominatim"));
}
},
checkOnboardingTooltip() {
@@ -2094,9 +2079,11 @@ export default {
},
positionOnboardingTooltip() {
this.$nextTick(() => {
if (!this.$refs.exportButton || !this.$refs.tooltipElement) return;
if (!this.$refs.exportToolButton || !this.$refs.tooltipElement) return;
const exportButton = this.$refs.exportButton;
const exportButton = Array.isArray(this.$refs.exportToolButton)
? this.$refs.exportToolButton[0]
: this.$refs.exportToolButton;
const tooltip = this.$refs.tooltipElement;
const buttonRect = exportButton.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
@@ -2617,7 +2604,7 @@ export default {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ToastUtils.success("Copied coordinates");
ToastUtils.success(this.$t("map.copied_coordinates"));
} else {
ToastUtils.success(text);
}
@@ -2891,7 +2878,7 @@ export default {
const response = await window.axios.get("/api/v1/map/drawings");
this.savedDrawings = response.data.drawings;
} catch {
ToastUtils.error("Failed to load drawings");
ToastUtils.error(this.$t("map.failed_load_drawings"));
} finally {
this.isLoadingDrawings = false;
}
@@ -2900,7 +2887,7 @@ export default {
async saveDrawing() {
if (!this.newDrawingName.trim()) return;
if (!this.drawSource) {
ToastUtils.error("Map not initialized");
ToastUtils.error(this.$t("map.not_initialized"));
return;
}
@@ -2916,11 +2903,11 @@ export default {
name: this.newDrawingName,
data: json,
});
ToastUtils.success("Drawing saved");
ToastUtils.success(this.$t("map.drawing_saved"));
this.showSaveDrawingModal = false;
this.newDrawingName = "";
} catch {
ToastUtils.error("Failed to save drawing");
ToastUtils.error(this.$t("map.failed_save_drawing"));
}
},
@@ -2942,9 +2929,9 @@ export default {
try {
await window.axios.delete(`/api/v1/map/drawings/${drawing.id}`);
this.savedDrawings = this.savedDrawings.filter((d) => d.id !== drawing.id);
ToastUtils.success("Deleted");
ToastUtils.success(this.$t("map.deleted"));
} catch {
ToastUtils.error("Failed to delete");
ToastUtils.error(this.$t("map.failed_delete"));
}
},
@@ -2975,11 +2962,11 @@ export default {
},
(err) => {
console.error("Geolocation failed", err);
ToastUtils.warning("Could not determine your location");
ToastUtils.warning(this.$t("map.location_not_determined"));
}
);
} else {
ToastUtils.warning("Geolocation is not supported by your browser");
ToastUtils.warning(this.$t("map.geolocation_not_supported"));
}
},
async fetchTelemetryMarkers() {
@@ -3148,7 +3135,7 @@ export default {
const nodesWithLoc = discovered.filter((n) => n.latitude != null && n.longitude != null);
if (nodesWithLoc.length === 0) {
ToastUtils.info("No discovered nodes with location found");
ToastUtils.info(this.$t("map.no_nodes_location"));
return;
}
@@ -3191,7 +3178,7 @@ export default {
ToastUtils.success(`Mapped ${nodesWithLoc.length} discovered nodes`);
} catch (e) {
console.error("Failed to map discovered nodes", e);
ToastUtils.error("Failed to fetch discovered nodes for mapping");
ToastUtils.error(this.$t("map.failed_fetch_nodes"));
}
},
},

View File

@@ -94,10 +94,10 @@ export default {
destination_hash: this.peer.destination_hash,
});
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert("User banished successfully");
DialogUtils.alert(this.$t("messages.user_banished"));
this.$emit("block-status-changed");
} catch (e) {
DialogUtils.alert("Failed to banish user");
DialogUtils.alert(this.$t("messages.failed_banish_user"));
console.log(e);
}
},
@@ -105,20 +105,16 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert("Banishment lifted successfully");
DialogUtils.alert(this.$t("banishment.banishment_lifted"));
this.$emit("block-status-changed");
} catch (e) {
DialogUtils.alert("Failed to lift banishment");
DialogUtils.alert(this.$t("banishment.failed_lift_banishment"));
console.log(e);
}
},
async onDeleteMessageHistory() {
// ask user to confirm deleting conversation history
if (
!(await DialogUtils.confirm(
"Are you sure you want to delete all messages in this conversation? This can not be undone!"
))
) {
if (!(await DialogUtils.confirm(this.$t("messages.delete_history_confirm")))) {
return;
}
@@ -126,7 +122,7 @@ export default {
try {
await window.axios.delete(`/api/v1/lxmf-messages/conversation/${this.peer.destination_hash}`);
} catch (e) {
DialogUtils.alert("failed to delete conversation");
DialogUtils.alert(this.$t("messages.failed_delete_history"));
console.log(e);
}
@@ -138,13 +134,13 @@ export default {
},
async onPingDestination() {
if (!this.peer || !this.peer.destination_hash) {
DialogUtils.alert("Invalid destination hash");
DialogUtils.alert(this.$t("messages.invalid_destination_hash"));
return;
}
const destinationHash = this.peer.destination_hash;
if (destinationHash.length !== 32 || !/^[0-9a-fA-F]+$/.test(destinationHash)) {
DialogUtils.alert("Invalid destination hash format");
DialogUtils.alert(this.$t("messages.invalid_destination_hash_format"));
return;
}
@@ -161,32 +157,32 @@ export default {
const rttDurationString = `${rttMilliseconds} ms`;
const info = [
`Valid reply from ${destinationHash}`,
`Duration: ${rttDurationString}`,
`Hops There: ${pingResult.hops_there}`,
`Hops Back: ${pingResult.hops_back}`,
this.$t("messages.ping_reply_from", { hash: destinationHash }),
this.$t("messages.duration", { duration: rttDurationString }),
this.$t("messages.hops_there", { count: pingResult.hops_there }),
this.$t("messages.hops_back", { count: pingResult.hops_back }),
];
// add signal quality if available
if (pingResult.quality != null) {
info.push(`Signal Quality: ${pingResult.quality}%`);
info.push(this.$t("messages.signal_quality", { quality: pingResult.quality }));
}
// add rssi if available
if (pingResult.rssi != null) {
info.push(`RSSI: ${pingResult.rssi}dBm`);
info.push(this.$t("messages.rssi_val", { rssi: pingResult.rssi }));
}
// add snr if available
if (pingResult.snr != null) {
info.push(`SNR: ${pingResult.snr}dB`);
info.push(this.$t("messages.snr_val", { snr: pingResult.snr }));
}
// show result
DialogUtils.alert(info.join("\n"));
} catch (e) {
console.log(e);
const message = e.response?.data?.message ?? "Ping failed. Try again later";
const message = e.response?.data?.message ?? this.$t("messages.ping_failed");
DialogUtils.alert(message);
}
},

View File

@@ -1681,10 +1681,10 @@ export default {
async copyMyAddress() {
try {
await navigator.clipboard.writeText(this.myLxmfAddressHash);
ToastUtils.success("Your LXMF address copied to clipboard");
ToastUtils.success(this.$t("messages.address_copied"));
} catch (e) {
console.error(e);
ToastUtils.error("Failed to copy address");
ToastUtils.error(this.$t("messages.failed_copy_address"));
}
},
focusComposeInput() {
@@ -1737,7 +1737,7 @@ export default {
}
} catch (e) {
console.error("Translation failed:", e);
ToastUtils.error("Translation failed");
ToastUtils.error(this.$t("messages.translation_failed"));
} finally {
this.isSendingMessage = false;
}
@@ -1949,7 +1949,7 @@ export default {
ToastUtils.success(`Added ${name} to contacts`);
} catch (e) {
console.error(e);
ToastUtils.error("Failed to add contact");
ToastUtils.error(this.$t("messages.failed_add_contact"));
}
},
async ingestPaperMessage(uri) {
@@ -1960,10 +1960,10 @@ export default {
uri: uri,
})
);
ToastUtils.info("Ingesting paper message...");
ToastUtils.info(this.$t("messages.ingesting_paper_message"));
} catch (e) {
console.error(e);
ToastUtils.error("Failed to ingest paper message");
ToastUtils.error(this.$t("messages.failed_ingest_paper"));
}
},
async generatePaperMessageFromComposition() {
@@ -2099,7 +2099,7 @@ export default {
destinationHash = destinationHash.replace("lxmf@", "");
}
if (destinationHash.length !== 32) {
DialogUtils.alert("Invalid Address");
DialogUtils.alert(this.$t("common.invalid_address"));
return;
}
GlobalEmitter.emit("compose-new-message", destinationHash);
@@ -2310,7 +2310,7 @@ export default {
}
// ask user for new display name
const displayName = await DialogUtils.prompt("Enter a custom display name");
const displayName = await DialogUtils.prompt(this.$t("messages.enter_display_name"));
if (displayName == null) {
return;
}
@@ -2331,7 +2331,7 @@ export default {
this.$emit("reload-conversations");
} catch (error) {
console.log(error);
DialogUtils.alert("Failed to update display name");
DialogUtils.alert(this.$t("messages.failed_update_display_name"));
}
},
async onConversationDeleted() {
@@ -2394,7 +2394,7 @@ export default {
}
} catch (e) {
console.error("Failed to download or decode audio:", e);
DialogUtils.alert("Failed to load audio attachment.");
DialogUtils.alert(this.$t("messages.failed_load_audio"));
} finally {
this.isDownloadingAudio[chatItem.lxmf_message.hash] = false;
}
@@ -2581,14 +2581,14 @@ export default {
this.contacts = response.data;
if (this.contacts.length === 0) {
ToastUtils.info("No contacts found in telephone");
ToastUtils.info(this.$t("messages.no_contacts_telephone"));
return;
}
this.isShareContactModalOpen = true;
} catch (e) {
console.error(e);
ToastUtils.error("Failed to load contacts");
ToastUtils.error(this.$t("messages.failed_load_contacts"));
}
},
shareContact(contact) {
@@ -2838,7 +2838,7 @@ export default {
async shareLocation() {
try {
if (!navigator.geolocation) {
DialogUtils.alert("Geolocation is not supported by your browser");
DialogUtils.alert(this.$t("map.geolocation_not_supported"));
return;
}
@@ -2885,10 +2885,10 @@ export default {
},
});
ToastUtils.success("Location request sent");
ToastUtils.success(this.$t("messages.location_request_sent"));
} catch (e) {
console.log(e);
ToastUtils.error("Failed to send location request");
ToastUtils.error(this.$t("messages.failed_send_location_request"));
}
},
viewLocationOnMap(location) {
@@ -2911,10 +2911,10 @@ export default {
async copyHash(hash) {
try {
await navigator.clipboard.writeText(hash);
ToastUtils.success("Hash copied to clipboard");
ToastUtils.success(this.$t("messages.hash_copied"));
} catch (e) {
console.error(e);
ToastUtils.error("Failed to copy hash");
ToastUtils.error(this.$t("messages.failed_to_copy_hash"));
}
},
formatBytes: function (bytes) {
@@ -2966,7 +2966,7 @@ export default {
}
} catch (err) {
console.error("Failed to read clipboard contents: ", err);
ToastUtils.error("Failed to read from clipboard");
ToastUtils.error(this.$t("messages.failed_read_clipboard"));
}
},
onFileInputChange: function (event) {
@@ -2979,7 +2979,7 @@ export default {
},
async removeImageAttachment(index) {
// ask user to confirm removing image attachment
if (!(await DialogUtils.confirm("Are you sure you want to remove this image attachment?"))) {
if (!(await DialogUtils.confirm(this.$t("messages.remove_image_confirm")))) {
return;
}
@@ -3037,7 +3037,7 @@ export default {
// alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
DialogUtils.alert("failed to start recording");
DialogUtils.alert(this.$t("messages.failed_start_recording"));
}
break;
@@ -3059,7 +3059,7 @@ export default {
// alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
DialogUtils.alert("failed to start recording");
DialogUtils.alert(this.$t("messages.failed_start_recording"));
}
break;
@@ -3146,7 +3146,7 @@ export default {
},
async removeAudioAttachment() {
// ask user to confirm removing audio attachment
if (!(await DialogUtils.confirm("Are you sure you want to remove this audio attachment?"))) {
if (!(await DialogUtils.confirm(this.$t("messages.remove_audio_confirm")))) {
return;
}

View File

@@ -256,7 +256,7 @@ export default {
}
if (destinationHash.length !== 32) {
DialogUtils.alert("Invalid Address");
DialogUtils.alert(this.$t("common.invalid_address"));
return;
}
@@ -422,18 +422,18 @@ export default {
try {
await window.axios.post("/api/v1/lxmf/folders", { name });
await this.getFolders();
ToastUtils.success("Folder created");
ToastUtils.success(this.$t("messages.folder_created"));
} catch {
ToastUtils.error("Failed to create folder");
ToastUtils.error(this.$t("messages.failed_create_folder"));
}
},
async onRenameFolder({ id, name }) {
try {
await window.axios.patch(`/api/v1/lxmf/folders/${id}`, { name });
await this.getFolders();
ToastUtils.success("Folder renamed");
ToastUtils.success(this.$t("messages.folder_renamed"));
} catch {
ToastUtils.error("Failed to rename folder");
ToastUtils.error(this.$t("messages.failed_rename_folder"));
}
},
async onDeleteFolder(id) {
@@ -444,9 +444,9 @@ export default {
}
await this.getFolders();
await this.getConversations();
ToastUtils.success("Folder deleted");
ToastUtils.success(this.$t("messages.folder_deleted"));
} catch {
ToastUtils.error("Failed to delete folder");
ToastUtils.error(this.$t("messages.failed_delete_folder"));
}
},
async onMoveToFolder({ peer_hashes, folder_id }) {
@@ -458,9 +458,9 @@ export default {
folder_id: targetFolderId,
});
await this.getConversations();
ToastUtils.success("Moved to folder");
ToastUtils.success(this.$t("messages.moved_to_folder"));
} catch {
ToastUtils.error("Failed to move to folder");
ToastUtils.error(this.$t("messages.failed_move_folder"));
}
},
async onBulkMarkAsRead(destination_hashes) {
@@ -469,9 +469,9 @@ export default {
destination_hashes,
});
await this.getConversations();
ToastUtils.success("Marked as read");
ToastUtils.success(this.$t("messages.marked_read"));
} catch {
ToastUtils.error("Failed to mark as read");
ToastUtils.error(this.$t("messages.failed_mark_read"));
}
},
async onBulkDelete(destination_hashes) {
@@ -486,9 +486,9 @@ export default {
destination_hashes,
});
await this.getConversations();
ToastUtils.success("Conversations deleted");
ToastUtils.success(this.$t("messages.conversations_deleted"));
} catch {
ToastUtils.error("Failed to delete conversations");
ToastUtils.error(this.$t("messages.failed_delete_conversations"));
}
},
async onExportFolders() {
@@ -503,7 +503,7 @@ export default {
a.click();
URL.revokeObjectURL(url);
} catch {
ToastUtils.error("Failed to export folders");
ToastUtils.error(this.$t("messages.failed_export_folders"));
}
},
async onImportFolders() {
@@ -520,9 +520,9 @@ export default {
await window.axios.post("/api/v1/lxmf/folders/import", data);
await this.getFolders();
await this.getConversations();
ToastUtils.success("Folders imported");
ToastUtils.success(this.$t("messages.folders_imported"));
} catch {
ToastUtils.error("Failed to import folders");
ToastUtils.error(this.$t("messages.failed_import_folders"));
}
};
reader.readAsText(file);
@@ -641,7 +641,7 @@ export default {
try {
this.ingestUri = await navigator.clipboard.readText();
} catch {
ToastUtils.error("Failed to read from clipboard");
ToastUtils.error(this.$t("messages.failed_read_clipboard"));
}
},
async ingestPaperMessage() {
@@ -656,7 +656,7 @@ export default {
);
this.isIngestModalOpen = false;
} catch {
ToastUtils.error("Failed to send ingest request");
ToastUtils.error(this.$t("messages.failed_send_ingest"));
}
},
getHashPopoutValue() {

View File

@@ -175,28 +175,30 @@
<!-- search + filters -->
<div
v-if="conversations.length > 0 || isFilterActive"
class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2"
class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-1.5"
>
<div class="flex gap-1">
<div class="flex gap-2">
<input
:value="conversationSearchTerm"
type="text"
:placeholder="$t('messages.search_placeholder', { count: conversations.length })"
class="input-field flex-1"
class="input-field flex-1 w-full rounded-none"
@input="onConversationSearchInput"
/>
</div>
<div class="flex items-center justify-end gap-1 px-1 text-gray-500 dark:text-gray-400">
<button
type="button"
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
class="p-1 rounded-lg hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
title="Selection Mode"
:class="{ 'text-blue-500 bg-blue-50 dark:bg-blue-900/20': selectionMode }"
:class="{ 'text-blue-500 dark:text-blue-400': selectionMode }"
@click="toggleSelectionMode"
>
<MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
</button>
<button
type="button"
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
class="p-1 rounded-lg hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
title="Ingest Paper Message"
@click="openIngestPaperMessageModal"
>
@@ -866,13 +868,16 @@ export default {
this.draggedHash = null;
},
async createFolder() {
const name = await DialogUtils.prompt("Enter folder name", "New Folder");
const name = await DialogUtils.prompt(
this.$t("messages.enter_folder_name"),
this.$t("messages.new_folder")
);
if (name) {
this.$emit("create-folder", name);
}
},
async renameFolder(folder) {
const name = await DialogUtils.prompt("Rename folder", folder.name);
const name = await DialogUtils.prompt(this.$t("messages.rename_folder"), folder.name);
if (name && name !== folder.name) {
this.$emit("rename-folder", { id: folder.id, name });
}

View File

@@ -250,7 +250,7 @@ export default {
}
} catch (err) {
console.error("Failed to render QR code:", err);
ToastUtils.error("Failed to render QR code");
ToastUtils.error(this.$t("messages.failed_render_qr"));
}
},
close() {
@@ -259,9 +259,9 @@ export default {
async copyUri() {
try {
await navigator.clipboard.writeText(this.uri);
ToastUtils.success("URI copied to clipboard");
ToastUtils.success(this.$t("messages.uri_copied"));
} catch {
ToastUtils.error("Failed to copy URI");
ToastUtils.error(this.$t("messages.failed_copy_uri"));
}
},
downloadQRCode() {
@@ -309,14 +309,14 @@ export default {
});
if (response.data.status === "success") {
ToastUtils.success("Paper message sent successfully");
ToastUtils.success(this.$t("messages.paper_message_sent"));
this.close();
} else {
ToastUtils.error(response.data.message || "Failed to send paper message");
}
} catch (err) {
console.error("Failed to send paper message:", err);
ToastUtils.error("Failed to send paper message");
ToastUtils.error(this.$t("messages.failed_send_paper"));
} finally {
this.isSending = false;
}

View File

@@ -127,6 +127,7 @@
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MicronParser from "micron-parser";
import { micronStorage } from "../../js/MicronStorage";
import DialogUtils from "../../js/DialogUtils";
export default {
name: "MicronEditorPage",
@@ -247,8 +248,8 @@ export default {
this.activeTabIndex = this.tabs.length - 1;
this.saveContent();
},
removeTab(index) {
if (confirm(this.$t("tools.micron_editor.confirm_delete_tab"))) {
async removeTab(index) {
if (await DialogUtils.confirm(this.$t("tools.micron_editor.confirm_delete_tab"))) {
this.tabs.splice(index, 1);
if (this.activeTabIndex >= this.tabs.length) {
this.activeTabIndex = Math.max(0, this.tabs.length - 1);
@@ -275,7 +276,7 @@ export default {
}
},
async resetAll() {
if (confirm(this.$t("tools.micron_editor.confirm_reset"))) {
if (await DialogUtils.confirm(this.$t("tools.micron_editor.confirm_reset"))) {
await micronStorage.clearAll();
this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
this.activeTabIndex = 0;

View File

@@ -33,7 +33,7 @@
v-if="totalBatches > 0"
class="flex justify-between items-center text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
>
<span>Batch {{ currentBatch }} / {{ totalBatches }}</span>
<span>{{ $t("visualiser.batch") }} {{ currentBatch }} / {{ totalBatches }}</span>
<span>{{ Math.round((loadedNodesCount / totalNodesToLoad) * 100) }}%</span>
</div>
</div>
@@ -53,12 +53,12 @@
@click="isShowingControls = !isShowingControls"
>
<div class="flex-1 flex flex-col min-w-0 mr-2">
<span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate"
>Reticulum Mesh</span
>
<span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate">{{
$t("visualiser.reticulum_mesh")
}}</span>
<span
class="text-[10px] uppercase font-bold text-gray-500 dark:text-zinc-500 tracking-widest truncate"
>Network Visualizer</span
>{{ $t("visualiser.network_visualizer") }}</span
>
</div>
<div class="flex items-center gap-2">
@@ -281,7 +281,9 @@ export default {
isLoading: false,
enablePhysics: true,
enableOrbit: false,
enableBouncingBalls: false,
orbitAnimationFrame: null,
bouncingBallsAnimationFrame: null,
loadingStatus: "Initializing...",
loadedNodesCount: 0,
totalNodesToLoad: 0,
@@ -323,11 +325,20 @@ export default {
},
enableOrbit(val) {
if (val) {
this.enableBouncingBalls = false;
this.startOrbit();
} else {
this.stopOrbit();
}
},
enableBouncingBalls(val) {
if (val) {
this.enableOrbit = false;
this.startBouncingBalls();
} else {
this.stopBouncingBalls();
}
},
searchQuery() {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
@@ -337,7 +348,11 @@ export default {
if (this._toggleOrbitHandler) {
GlobalEmitter.off("toggle-orbit", this._toggleOrbitHandler);
}
if (this._toggleBouncingBallsHandler) {
GlobalEmitter.off("toggle-bouncing-balls", this._toggleBouncingBallsHandler);
}
this.stopOrbit();
this.stopBouncingBalls();
clearInterval(this.reloadInterval);
if (this.network) {
this.network.destroy();
@@ -359,6 +374,11 @@ export default {
};
GlobalEmitter.on("toggle-orbit", this._toggleOrbitHandler);
this._toggleBouncingBallsHandler = () => {
this.enableBouncingBalls = !this.enableBouncingBalls;
};
GlobalEmitter.on("toggle-bouncing-balls", this._toggleBouncingBallsHandler);
this.init();
},
methods: {
@@ -541,6 +561,7 @@ export default {
startOrbit() {
if (!this.network) return;
this.stopOrbit();
this.stopBouncingBalls();
// Disable physics while orbiting
this.network.setOptions({ physics: { enabled: false } });
@@ -582,6 +603,19 @@ export default {
const mePos = positions["me"] || { x: 0, y: 0 };
const updates = this._orbitNodes.map((data) => {
if (data.id === this._draggingNodeId) {
// If dragging, update our internal radius/angle to match new position
const nodePositions = this.network.getPositions([data.id]);
const pos = nodePositions[data.id];
if (pos) {
const dx = pos.x - mePos.x;
const dy = pos.y - mePos.y;
data.radius = Math.sqrt(dx * dx + dy * dy);
data.angle = Math.atan2(dy, dx);
}
return { id: data.id, x: pos.x, y: pos.y };
}
data.angle += data.speed;
return {
id: data.id,
@@ -612,6 +646,127 @@ export default {
this.network.setOptions({ physics: { enabled: this.enablePhysics } });
}
},
startBouncingBalls() {
if (!this.network) return;
this.stopBouncingBalls();
this.stopOrbit();
// Disable physics
this.network.setOptions({ physics: { enabled: false } });
// Hide edges
const edges = this.edges.get();
const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: true }));
this.edges.update(updatedEdges);
const container = document.getElementById("network");
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight;
const scale = this.network.getScale();
const viewPosition = this.network.getViewPosition();
const halfWidth = width / scale / 2;
const halfHeight = height / scale / 2;
const topBound = viewPosition.y - halfHeight;
const leftBound = viewPosition.x - halfWidth;
const rightBound = viewPosition.x + halfWidth;
const nodeIds = this.nodes.getIds();
this._bouncingNodes = nodeIds.map((id) => {
const node = this.nodes.get(id);
// Get current canvas position if available, otherwise randomize
const currentPos = this.network.getPositions([id])[id] || {
x: leftBound + Math.random() * (rightBound - leftBound),
y: topBound - Math.random() * 800 - 100,
};
return {
id: id,
x: currentPos.x,
y: currentPos.y < topBound ? currentPos.y : topBound - Math.random() * 800 - 100, // ensure they start above or at their current high pos
vx: (Math.random() - 0.5) * 15,
vy: Math.random() * 10,
radius: (node.size || 25) * 1.5, // approximate collision radius
};
});
const gravity = 0.45;
const friction = 0.99;
const bounce = 0.75;
const animate = () => {
if (!this.enableBouncingBalls) return;
// Re-calculate boundaries in case of zoom/pan
const scale = this.network.getScale();
const viewPosition = this.network.getViewPosition();
const halfWidth = width / scale / 2;
const halfHeight = height / scale / 2;
const bottomBound = viewPosition.y + halfHeight;
const leftBound = viewPosition.x - halfWidth;
const rightBound = viewPosition.x + halfWidth;
const updates = this._bouncingNodes.map((node) => {
if (node.id === this._draggingNodeId) {
return {
id: node.id,
x: node.x,
y: node.y,
};
}
node.vy += gravity;
node.vx *= friction;
node.vy *= friction;
node.x += node.vx;
node.y += node.vy;
// Bounce off bottom
if (node.y + node.radius > bottomBound) {
node.y = bottomBound - node.radius;
node.vy *= -bounce;
node.vx += (Math.random() - 0.5) * 4;
}
// Bounce off sides
if (node.x - node.radius < leftBound) {
node.x = leftBound + node.radius;
node.vx *= -bounce;
} else if (node.x + node.radius > rightBound) {
node.x = rightBound - node.radius;
node.vx *= -bounce;
}
return {
id: node.id,
x: node.x,
y: node.y,
};
});
this.nodes.update(updates);
this.bouncingBallsAnimationFrame = requestAnimationFrame(animate);
};
this.bouncingBallsAnimationFrame = requestAnimationFrame(animate);
},
stopBouncingBalls() {
if (this.bouncingBallsAnimationFrame) {
cancelAnimationFrame(this.bouncingBallsAnimationFrame);
this.bouncingBallsAnimationFrame = null;
}
// Restore edges visibility
const edges = this.edges.get();
const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: false }));
this.edges.update(updatedEdges);
// Re-enable physics if it was enabled
if (this.network) {
this.network.setOptions({ physics: { enabled: this.enablePhysics } });
}
},
async init() {
const container = document.getElementById("network");
const isDarkMode = document.documentElement.classList.contains("dark");
@@ -700,6 +855,36 @@ export default {
}
});
this.network.on("dragStart", (params) => {
if ((this.enableBouncingBalls || this.enableOrbit) && params.nodes.length > 0) {
this._draggingNodeId = params.nodes[0];
this.network.setOptions({ physics: { enabled: false } });
}
});
this.network.on("dragging", (params) => {
if (this._draggingNodeId) {
const canvasPos = params.pointer.canvas;
if (this.enableBouncingBalls) {
const node = this._bouncingNodes.find((n) => n.id === this._draggingNodeId);
if (node) {
node.vx = (canvasPos.x - node.x) * 0.5;
node.vy = (canvasPos.y - node.y) * 0.5;
node.x = canvasPos.x;
node.y = canvasPos.y;
}
} else if (this.enableOrbit) {
// For orbit mode, just update the node position in vis-network DataSet
// though it might be overwritten by orbit animation loop
this.nodes.update({ id: this._draggingNodeId, x: canvasPos.x, y: canvasPos.y });
}
}
});
this.network.on("dragEnd", () => {
this._draggingNodeId = null;
});
await this.manualUpdate();
// auto reload

View File

@@ -1230,7 +1230,7 @@ export default {
}
// unsupported url
ToastUtils.warning("unsupported url: " + url);
ToastUtils.warning(this.$t("nomadnet.unsupported_url") + url);
},
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
// create blob from base64 encoded file bytes
@@ -1288,7 +1288,7 @@ export default {
await this.getFavourites();
} catch (e) {
console.log(e);
ToastUtils.error("Failed to rename favourite");
ToastUtils.error(this.$t("nomadnet.failed_rename_favourite"));
}
},
async onRemoveFavourite(favourite) {

View File

@@ -27,85 +27,230 @@
v-model="favouritesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_favourites_placeholder', { count: favourites.length })"
class="input-field"
class="input-field w-full rounded-none"
/>
</div>
<div
class="flex items-center justify-between px-3 pt-2 text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
<span class="font-semibold">Sections</span>
<button
type="button"
class="inline-flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
@click="createSection"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
<span>Add Section</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
<div
v-for="favourite of searchedFavourites"
:key="favourite.destination_hash"
class="favourite-card relative"
:class="[
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : '',
]"
draggable="true"
@click="onFavouriteClick(favourite)"
@dragstart="onFavouriteDragStart($event, favourite)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, favourite)"
@dragend="onFavouriteDragEnd"
>
<!-- banished overlay -->
<div
v-if="GlobalState.config.banished_effect_enabled && isBlocked(favourite.destination_hash)"
class="banished-overlay"
:style="{ background: GlobalState.config.banished_color + '33' }"
>
<span
class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
:style="{ 'background-color': GlobalState.config.banished_color }"
>{{ GlobalState.config.banished_text }}</span
>
</div>
<div class="favourite-card__icon flex-shrink-0">
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:title="favourite.display_name"
>
{{ favourite.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDestinationHash(favourite.destination_hash) }}
</div>
</div>
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5" />
<span>{{ $t("nomadnet.rename") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500" />
<span class="text-red-500">{{ $t("nomadnet.remove") }}</span>
</DropDownMenuItem>
<div v-if="isBlocked(favourite.destination_hash)" class="border-t">
<DropDownMenuItem @click.stop="onUnblockNode(favourite.destination_hash)">
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
<span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div>
</div>
<div v-else class="empty-state">
<div v-if="favourites.length === 0" class="empty-state">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("nomadnet.add_nodes_from_announces") }}
</div>
</div>
<div v-else-if="hasFavouriteResults" class="space-y-3 pt-2">
<div
v-for="section in sectionsWithFavourites"
:key="section.id"
class="rounded-xl"
:class="[
dragOverSectionId === section.id
? 'ring-1 ring-blue-400 dark:ring-blue-600 bg-blue-50/40 dark:bg-blue-900/10'
: '',
draggingSectionOverId === section.id
? 'ring-1 ring-blue-300 dark:ring-blue-700 bg-blue-50/30 dark:bg-blue-900/5'
: '',
]"
@dragover.prevent="onSectionDragOver(section.id)"
@dragleave="onSectionDragLeave"
@drop.prevent="onDropOnSection(section.id)"
>
<div
class="flex items-center justify-between px-2 py-1 cursor-pointer select-none"
draggable="true"
@click="toggleSectionCollapse(section.id)"
@contextmenu.prevent="openSectionContextMenu($event, section)"
@dragstart="onSectionDragStart(section.id)"
@dragover.prevent="onSectionReorderDragOver(section.id)"
@drop.prevent="onSectionDrop(section.id)"
@dragend="onSectionDragEnd"
>
<div class="flex items-center gap-2">
<MaterialDesignIcon
:icon-name="section.collapsed ? 'chevron-right' : 'chevron-down'"
class="size-4 text-gray-400"
/>
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300"
>
{{ section.name }}
</span>
<span
v-if="section.collapsed"
class="text-[10px] font-semibold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full"
>
{{ section.favourites.length }}
</span>
</div>
<div class="flex items-center gap-1" @click.stop>
<button
type="button"
class="p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 rounded-lg transition"
@click="openSectionContextMenu($event, section)"
>
<MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
</button>
</div>
</div>
<div class="h-px bg-gray-200 dark:bg-zinc-800 mx-1"></div>
<div v-if="!section.collapsed" class="space-y-2 pt-2 pb-1 px-1">
<div
v-for="favourite of section.favourites"
:key="favourite.destination_hash"
class="favourite-card relative"
:class="[
favourite.destination_hash === selectedDestinationHash
? 'favourite-card--active'
: '',
draggingFavouriteHash === favourite.destination_hash
? 'favourite-card--dragging'
: '',
]"
draggable="true"
@click="onFavouriteClick(favourite)"
@contextmenu.prevent="openFavouriteContextMenu($event, favourite, section.id)"
@dragstart="onFavouriteDragStart($event, favourite, section.id)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, section.id, favourite)"
@dragend="onFavouriteDragEnd"
>
<div
v-if="
GlobalState.config.banished_effect_enabled &&
isBlocked(favourite.destination_hash)
"
class="banished-overlay"
:style="{ background: GlobalState.config.banished_color + '33' }"
>
<span
class="banished-text !text-[10px] !opacity-100 !tracking-widest !border !px-1 !py-0.5 !text-white !shadow-lg"
:style="{ 'background-color': GlobalState.config.banished_color }"
>{{ GlobalState.config.banished_text }}</span
>
</div>
<div class="favourite-card__icon flex-shrink-0">
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:title="favourite.display_name"
>
{{ favourite.display_name }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer inline-flex items-center"
:title="$t('common.copy_to_clipboard')"
@click.stop="copyToClipboard(favourite.destination_hash, 'Address')"
>
{{ formatDestinationHash(favourite.destination_hash) }}
</div>
</div>
<IconButton
class="text-gray-500 dark:text-gray-300"
@click.stop="openFavouriteContextMenu($event, favourite, section.id)"
>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
</IconButton>
</div>
<div
v-if="section.favourites.length === 0"
class="text-xs text-gray-500 dark:text-gray-400 px-3 pb-2 italic"
>
No favourites in this section.
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
<div class="font-semibold">No favourites match your search</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Try a different search term.</div>
</div>
</div>
<!-- Favourite Context Menu -->
<div
v-if="favouriteContextMenu.show"
v-click-outside="{ handler: closeContextMenus, capture: true }"
class="fixed z-[100] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="renameFavouriteFromContext"
>
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
<span class="font-medium">{{ $t("nomadnet.rename") }}</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
@click="removeFavouriteFromContext"
>
<MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
<span class="font-medium">{{ $t("nomadnet.remove") }}</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<div
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
>
Move to Section
</div>
<div class="max-h-56 overflow-y-auto custom-scrollbar">
<button
v-for="section in sectionsWithFavourites"
:key="section.id + '-move'"
type="button"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
@click="moveContextFavouriteToSection(section.id)"
>
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
<span class="truncate">{{ section.name }}</span>
</button>
</div>
</div>
<!-- Section Context Menu -->
<div
v-if="sectionContextMenu.show"
v-click-outside="{ handler: closeContextMenus, capture: true }"
class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="renameSectionFromContext"
>
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
<span class="font-medium">Rename Section</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
:disabled="sectionContextMenu.sectionId === defaultSectionId"
:class="sectionContextMenu.sectionId === defaultSectionId ? 'opacity-50 cursor-not-allowed' : ''"
@click="removeSectionFromContext"
>
<MaterialDesignIcon icon-name="delete" class="size-4 text-red-400" />
<span class="font-medium">Delete Section</span>
</button>
</div>
</div>
@@ -115,7 +260,7 @@
:value="nodesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
class="input-field"
class="input-field w-full rounded-none"
@input="onNodesSearchInput"
/>
</div>
@@ -151,8 +296,17 @@
>
{{ node.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) }) }}
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
<span class="truncate">{{
$t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) })
}}</span>
<span
class="cursor-pointer hover:text-blue-500 dark:hover:text-blue-400 inline-flex items-center"
:title="$t('common.copy_to_clipboard')"
@click.stop="copyToClipboard(node.destination_hash, 'Address')"
>
{{ formatDestinationHash(node.destination_hash) }}
</span>
</div>
</div>
</div>
@@ -199,6 +353,7 @@ import DropDownMenuItem from "../DropDownMenuItem.vue";
import DialogUtils from "../../js/DialogUtils";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "NomadNetworkSidebar",
@@ -239,8 +394,28 @@ export default {
GlobalState,
tab: "favourites",
favouritesSearchTerm: "",
favouritesOrder: [],
defaultSectionId: "default",
sections: [],
sectionOrder: [],
favouritesBySection: {},
draggingFavouriteHash: null,
draggingFavouriteSectionId: null,
dragOverSectionId: null,
draggingSectionId: null,
draggingSectionOverId: null,
favouriteContextMenu: {
show: false,
x: 0,
y: 0,
targetHash: null,
targetSectionId: null,
},
sectionContextMenu: {
show: false,
x: 0,
y: 0,
sectionId: null,
},
};
},
computed: {
@@ -267,37 +442,155 @@ export default {
return matchesDisplayName || matchesDestinationHash;
});
},
orderedFavourites() {
return [...this.favourites].sort((a, b) => {
return (
this.favouritesOrder.indexOf(a.destination_hash) - this.favouritesOrder.indexOf(b.destination_hash)
);
orderedSections() {
const map = {};
this.sections.forEach((section) => {
map[section.id] = section;
});
const ids = this.sectionOrder.length > 0 ? this.sectionOrder : this.sections.map((section) => section.id);
return ids.map((id) => map[id]).filter((section) => section);
},
sectionsWithFavourites() {
const search = this.favouritesSearchTerm.toLowerCase();
return this.orderedSections.map((section) => {
const hashes = this.favouritesBySection[section.id] || [];
const favourites = hashes
.map((hash) => this.favourites.find((fav) => fav.destination_hash === hash))
.filter((fav) => fav)
.filter((fav) => this.matchesFavouriteSearch(fav, search));
return { ...section, favourites };
});
},
searchedFavourites() {
return this.orderedFavourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName =
favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
hasFavouriteResults() {
if (this.favourites.length === 0) {
return false;
}
if (this.favouritesSearchTerm.trim() === "") {
return true;
}
return this.sectionsWithFavourites.some((section) => section.favourites.length > 0);
},
},
watch: {
favourites: {
handler() {
this.ensureFavouriteOrder();
this.ensureFavouriteLayout();
},
deep: true,
},
},
mounted() {
this.loadFavouriteOrder();
this.ensureFavouriteOrder();
this.loadFavouriteLayout();
this.ensureFavouriteLayout();
},
methods: {
matchesFavouriteSearch(favourite, searchTerm = this.favouritesSearchTerm.toLowerCase()) {
const matchesDisplayName = favourite.display_name.toLowerCase().includes(searchTerm);
const matchesCustomDisplayName =
favourite.custom_display_name?.toLowerCase()?.includes(searchTerm) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(searchTerm);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
},
buildDefaultSection() {
return {
id: this.defaultSectionId,
name: this.$t("nomadnet.favourites"),
collapsed: false,
};
},
resetDefaultSections() {
const defaultSection = this.buildDefaultSection();
this.sections = [defaultSection];
this.sectionOrder = [defaultSection.id];
this.favouritesBySection = { [defaultSection.id]: [] };
},
loadFavouriteLayout() {
try {
const stored = localStorage.getItem("meshchat.nomadnet.favourites.layout");
if (stored) {
const parsed = JSON.parse(stored);
this.sections = parsed.sections || [];
this.sectionOrder =
parsed.sectionOrder ||
(parsed.sections ? parsed.sections.map((section) => section.id) : this.sectionOrder);
this.favouritesBySection = parsed.favouritesBySection || {};
return;
}
const legacyOrder = localStorage.getItem("meshchat.nomadnet.favourites");
if (legacyOrder) {
const parsedOrder = JSON.parse(legacyOrder);
const defaultSection = this.buildDefaultSection();
this.sections = [defaultSection];
this.sectionOrder = [defaultSection.id];
this.favouritesBySection = { [defaultSection.id]: parsedOrder };
}
} catch (e) {
console.log(e);
}
if (this.sections.length === 0) {
this.resetDefaultSections();
}
},
persistFavouriteLayout() {
try {
localStorage.setItem(
"meshchat.nomadnet.favourites.layout",
JSON.stringify({
sections: this.sections,
sectionOrder: this.sectionOrder,
favouritesBySection: this.favouritesBySection,
})
);
} catch (e) {
console.log(e);
}
},
ensureFavouriteLayout() {
if (this.sections.length === 0) {
this.resetDefaultSections();
}
const hashes = this.favourites.map((fav) => fav.destination_hash);
const sectionIds = new Set();
const sanitizedSections = [];
this.sections.forEach((section) => {
if (!section || !section.id || sectionIds.has(section.id)) {
return;
}
sectionIds.add(section.id);
sanitizedSections.push({
id: section.id,
name: section.name || this.$t("nomadnet.favourites"),
collapsed: section.collapsed === true ? true : false,
});
});
if (!sectionIds.has(this.defaultSectionId)) {
const defaultSection = this.buildDefaultSection();
sanitizedSections.unshift(defaultSection);
sectionIds.add(defaultSection.id);
}
this.sections = sanitizedSections;
const existingOrder = Array.isArray(this.sectionOrder) ? this.sectionOrder : [];
const filteredOrder = existingOrder.filter((id) => sectionIds.has(id));
const remaining = sanitizedSections
.map((section) => section.id)
.filter((id) => !filteredOrder.includes(id));
this.sectionOrder = [...filteredOrder, ...remaining];
const nextFavouritesBySection = {};
sanitizedSections.forEach((section) => {
const existing = this.favouritesBySection[section.id] || [];
nextFavouritesBySection[section.id] = existing.filter((hash) => hashes.includes(hash));
});
const assigned = new Set(Object.values(nextFavouritesBySection).flat());
hashes.forEach((hash) => {
if (!assigned.has(hash)) {
nextFavouritesBySection[this.defaultSectionId].push(hash);
assigned.add(hash);
}
});
this.favouritesBySection = nextFavouritesBySection;
this.persistFavouriteLayout();
},
isBlocked(identityHash) {
return this.blockedDestinations.some((b) => b.destination_hash === identityHash);
},
@@ -321,9 +614,9 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${identityHash}`);
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert("Banishment lifted successfully");
DialogUtils.alert(this.$t("nomadnet.banishment_lifted"));
} catch (e) {
DialogUtils.alert("Failed to lift banishment");
DialogUtils.alert(this.$t("nomadnet.failed_lift_banishment"));
console.log(e);
}
},
@@ -345,33 +638,7 @@ export default {
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
loadFavouriteOrder() {
try {
const stored = localStorage.getItem("meshchat.nomadnet.favourites");
if (stored) {
this.favouritesOrder = JSON.parse(stored);
}
} catch (e) {
console.log(e);
}
},
persistFavouriteOrder() {
localStorage.setItem("meshchat.nomadnet.favourites", JSON.stringify(this.favouritesOrder));
},
ensureFavouriteOrder() {
const hashes = this.favourites.map((fav) => fav.destination_hash);
const nextOrder = this.favouritesOrder.filter((hash) => hashes.includes(hash));
hashes.forEach((hash) => {
if (!nextOrder.includes(hash)) {
nextOrder.push(hash);
}
});
if (JSON.stringify(nextOrder) !== JSON.stringify(this.favouritesOrder)) {
this.favouritesOrder = nextOrder;
this.persistFavouriteOrder();
}
},
onFavouriteDragStart(event, favourite) {
onFavouriteDragStart(event, favourite, sectionId) {
try {
if (event?.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
@@ -381,30 +648,214 @@ export default {
// ignore for browsers that prevent setting drag meta
}
this.draggingFavouriteHash = favourite.destination_hash;
this.draggingFavouriteSectionId = sectionId;
},
onFavouriteDragOver(event) {
if (event?.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
},
onFavouriteDrop(event, targetFavourite) {
onFavouriteDrop(event, targetSectionId, targetFavourite) {
if (!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash) {
return;
}
const fromIndex = this.favouritesOrder.indexOf(this.draggingFavouriteHash);
const toIndex = this.favouritesOrder.indexOf(targetFavourite.destination_hash);
if (fromIndex === -1 || toIndex === -1) {
return;
}
const updated = [...this.favouritesOrder];
updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, this.draggingFavouriteHash);
this.favouritesOrder = updated;
this.persistFavouriteOrder();
this.draggingFavouriteHash = null;
this.moveFavouriteToSection(this.draggingFavouriteHash, targetSectionId, targetFavourite.destination_hash);
},
onFavouriteDragEnd() {
this.draggingFavouriteHash = null;
this.draggingFavouriteSectionId = null;
this.dragOverSectionId = null;
},
onSectionDragOver(sectionId) {
this.dragOverSectionId = sectionId;
},
onSectionDragLeave() {
this.dragOverSectionId = null;
},
onDropOnSection(sectionId) {
if (!this.draggingFavouriteHash) {
return;
}
this.moveFavouriteToSection(this.draggingFavouriteHash, sectionId);
},
onSectionDragStart(sectionId) {
this.draggingSectionId = sectionId;
},
onSectionReorderDragOver(sectionId) {
if (!this.draggingSectionId || this.draggingSectionId === sectionId) {
return;
}
this.draggingSectionOverId = sectionId;
},
onSectionDrop(targetSectionId) {
if (!this.draggingSectionId || this.draggingSectionId === targetSectionId) {
this.onSectionDragEnd();
return;
}
const currentOrder = [...this.sectionOrder];
const fromIndex = currentOrder.indexOf(this.draggingSectionId);
const toIndex = currentOrder.indexOf(targetSectionId);
if (fromIndex === -1 || toIndex === -1) {
this.onSectionDragEnd();
return;
}
currentOrder.splice(fromIndex, 1);
currentOrder.splice(toIndex, 0, this.draggingSectionId);
this.sectionOrder = currentOrder;
this.persistFavouriteLayout();
this.onSectionDragEnd();
},
onSectionDragEnd() {
this.draggingSectionId = null;
this.draggingSectionOverId = null;
},
moveFavouriteToSection(hash, targetSectionId, beforeHash = null) {
if (!hash || !targetSectionId) {
return;
}
const updated = {};
Object.keys(this.favouritesBySection).forEach((sectionKey) => {
updated[sectionKey] = (this.favouritesBySection[sectionKey] || []).filter((value) => value !== hash);
});
if (!updated[targetSectionId]) {
updated[targetSectionId] = [];
}
const targetList = [...updated[targetSectionId]];
if (beforeHash) {
const insertIndex = targetList.indexOf(beforeHash);
if (insertIndex === -1) {
targetList.push(hash);
} else {
targetList.splice(insertIndex, 0, hash);
}
} else {
targetList.push(hash);
}
updated[targetSectionId] = targetList;
this.favouritesBySection = updated;
this.persistFavouriteLayout();
this.draggingFavouriteHash = null;
this.draggingFavouriteSectionId = null;
this.dragOverSectionId = null;
},
openFavouriteContextMenu(event, favourite, sectionId) {
this.favouriteContextMenu = {
show: true,
x: event.pageX || event.clientX,
y: event.pageY || event.clientY,
targetHash: favourite.destination_hash,
targetSectionId: sectionId,
};
this.sectionContextMenu.show = false;
},
openSectionContextMenu(event, section) {
this.sectionContextMenu = {
show: true,
x: event.pageX || event.clientX,
y: event.pageY || event.clientY,
sectionId: section.id,
};
this.favouriteContextMenu.show = false;
},
closeContextMenus() {
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
},
getFavouriteByHash(hash) {
return this.favourites.find((fav) => fav.destination_hash === hash);
},
renameFavouriteFromContext() {
const favourite = this.getFavouriteByHash(this.favouriteContextMenu.targetHash);
if (!favourite) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
this.onRenameFavourite(favourite);
},
removeFavouriteFromContext() {
const favourite = this.getFavouriteByHash(this.favouriteContextMenu.targetHash);
if (!favourite) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
this.onRemoveFavourite(favourite);
},
moveContextFavouriteToSection(sectionId) {
if (!this.favouriteContextMenu.targetHash) {
return;
}
this.moveFavouriteToSection(this.favouriteContextMenu.targetHash, sectionId);
this.closeContextMenus();
},
toggleSectionCollapse(sectionId) {
const idx = this.sections.findIndex((section) => section.id === sectionId);
if (idx === -1) {
return;
}
const updated = [...this.sections];
const section = { ...updated[idx] };
section.collapsed = !section.collapsed;
updated[idx] = section;
this.sections = updated;
this.persistFavouriteLayout();
},
async createSection() {
const name = await DialogUtils.prompt(
this.$t("nomadnet.enter_section_name"),
this.$t("nomadnet.new_section")
);
if (!name) {
return;
}
const section = {
id: `section-${Date.now()}`,
name,
collapsed: false,
};
this.sections = [...this.sections, section];
this.sectionOrder = [...this.sectionOrder, section.id];
this.favouritesBySection = { ...this.favouritesBySection, [section.id]: [] };
this.persistFavouriteLayout();
},
async renameSectionFromContext() {
const section = this.sections.find((sec) => sec.id === this.sectionContextMenu.sectionId);
if (!section) {
this.closeContextMenus();
return;
}
const name = await DialogUtils.prompt(this.$t("nomadnet.rename_section"), section.name);
if (!name || name === section.name) {
this.closeContextMenus();
return;
}
this.sections = this.sections.map((sec) => (sec.id === section.id ? { ...sec, name } : sec));
this.persistFavouriteLayout();
this.closeContextMenus();
},
async removeSectionFromContext() {
const sectionId = this.sectionContextMenu.sectionId;
if (!sectionId || sectionId === this.defaultSectionId) {
this.closeContextMenus();
return;
}
const confirmed = await DialogUtils.confirm(this.$t("nomadnet.delete_section_confirm"));
if (!confirmed) {
this.closeContextMenus();
return;
}
const retainedSections = this.sections.filter((section) => section.id !== sectionId);
const migrated = this.favouritesBySection[sectionId] || [];
const nextMap = { ...this.favouritesBySection };
delete nextMap[sectionId];
nextMap[this.defaultSectionId] = [...(nextMap[this.defaultSectionId] || []), ...migrated];
this.sections = retainedSections;
this.sectionOrder = this.sectionOrder.filter((id) => id !== sectionId);
this.favouritesBySection = nextMap;
this.persistFavouriteLayout();
this.closeContextMenus();
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
@@ -412,6 +863,11 @@ export default {
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
copyToClipboard(text, label) {
if (!text) return;
navigator.clipboard.writeText(text);
ToastUtils.success(`${label} copied to clipboard`);
},
onNodesSearchInput(event) {
this.$emit("nodes-search-changed", event.target.value);
},

View File

@@ -318,7 +318,7 @@ export default {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
ToastUtils.error("Failed to load configuration");
ToastUtils.error(this.$t("messages.failed_load_config"));
console.error(e);
}
},
@@ -331,12 +331,12 @@ export default {
this.saveOriginalValues();
if (!silent) {
ToastUtils.success("Profile icon saved successfully");
ToastUtils.success(this.$t("messages.profile_icon_saved"));
}
return true;
} catch (e) {
if (!silent) {
ToastUtils.error("Failed to save profile icon");
ToastUtils.error(this.$t("messages.failed_save_profile_icon"));
}
console.error(e);
return false;
@@ -348,12 +348,12 @@ export default {
}
if (!this.iconForegroundColour || !this.iconBackgroundColour) {
ToastUtils.warning("Please select both background and icon colors");
ToastUtils.warning(this.$t("messages.select_colors_warning"));
return;
}
if (!this.iconName) {
ToastUtils.warning("Please select an icon");
ToastUtils.warning(this.$t("messages.select_icon_warning"));
return;
}
@@ -370,7 +370,7 @@ export default {
);
if (success && !silent) {
ToastUtils.success("Profile icon saved successfully");
ToastUtils.success(this.$t("messages.profile_icon_saved"));
}
} finally {
this.isSaving = false;
@@ -385,7 +385,7 @@ export default {
this.iconForegroundColour = this.originalIconForegroundColour;
this.iconBackgroundColour = this.originalIconBackgroundColour;
ToastUtils.info("Changes reset to saved values");
ToastUtils.info(this.$t("messages.changes_reset"));
},
onIconClick(iconName) {
this.iconName = iconName;
@@ -401,7 +401,7 @@ export default {
});
if (success) {
ToastUtils.success("Profile icon removed successfully");
ToastUtils.success(this.$t("messages.profile_icon_removed"));
}
} finally {
this.isSaving = false;

View File

@@ -374,7 +374,7 @@ export default {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch (e) {
ToastUtils.error("Failed to save config!");
ToastUtils.error(this.$t("common.save_failed"));
console.log(e);
}
},

View File

@@ -242,12 +242,12 @@ export default {
this.identities = response.data.identities;
} catch (e) {
console.error(e);
ToastUtils.error("Failed to load identities");
ToastUtils.error(this.$t("identities.failed_load"));
}
},
async createIdentity() {
if (!this.newIdentityName) {
ToastUtils.warning("Please enter a display name");
ToastUtils.warning(this.$t("identities.enter_display_name_warning"));
return;
}
@@ -256,13 +256,13 @@ export default {
await window.axios.post("/api/v1/identities/create", {
display_name: this.newIdentityName,
});
ToastUtils.success("Identity created successfully");
ToastUtils.success(this.$t("identities.created"));
this.showCreateModal = false;
this.newIdentityName = "";
await this.getIdentities();
} catch (e) {
console.error(e);
ToastUtils.error("Failed to create identity");
ToastUtils.error(this.$t("identities.failed_create"));
} finally {
this.isCreating = false;
}
@@ -286,14 +286,15 @@ export default {
// Success is handled by GlobalEmitter "identity-switched" which we listen to
ToastUtils.success(this.$t("identities.switched") || "Identity switched successfully");
} else {
ToastUtils.info("Switch scheduled. Reloading application...");
ToastUtils.info(this.$t("identities.switch_scheduled"));
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (e) {
console.error(e);
const errorMsg = e.response?.data?.message || "Failed to switch identity";
const errorMsg =
e.response?.data?.message || this.$t("identities.failed_switch") || "Failed to switch identity";
ToastUtils.error(errorMsg);
this.isCreating = false;
@@ -313,7 +314,7 @@ export default {
await this.getIdentities();
} catch (e) {
console.error(e);
ToastUtils.error("Failed to delete identity");
ToastUtils.error(this.$t("identities.failed_delete"));
}
},
},

View File

@@ -105,10 +105,55 @@
</div>
</div>
<!-- search bar -->
<div class="sticky top-0 z-10 py-4">
<div class="relative max-w-6xl mx-auto">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
<input
v-model="searchQuery"
type="text"
class="w-full bg-white/80 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
:placeholder="$t('app.search_settings') || 'Search settings...'"
/>
<button
v-if="searchQuery"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
@click="searchQuery = ''"
>
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
</button>
</div>
</div>
<!-- no results -->
<div
v-if="searchQuery && !hasSearchResults"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div
class="p-4 bg-white/50 dark:bg-zinc-800/50 rounded-full mb-4 border border-gray-100 dark:border-zinc-800"
>
<MaterialDesignIcon icon-name="magnify-close" class="size-8 text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">No results found</h3>
<p class="text-gray-500 dark:text-gray-400">No settings match "{{ searchQuery }}"</p>
<button
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-xl hover:bg-blue-600 transition font-semibold text-sm"
@click="searchQuery = ''"
>
Clear search
</button>
</div>
<!-- settings grid -->
<div class="columns-1 lg:columns-2 gap-4 space-y-4">
<div v-show="hasSearchResults" class="columns-1 lg:columns-2 gap-4 space-y-4">
<!-- Banishment -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.banishment)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Visuals</div>
@@ -174,7 +219,10 @@
</section>
<!-- Maintenance & Data -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.maintenance)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Maintenance</div>
@@ -335,7 +383,11 @@
</section>
<!-- Desktop / Electron Settings -->
<section v-if="ElectronUtils.isElectron()" class="glass-card break-inside-avoid">
<section
v-if="ElectronUtils.isElectron()"
v-show="matchesSearch(...sectionKeywords.desktop)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Desktop</div>
@@ -381,7 +433,7 @@
</section>
<!-- Page Archiver -->
<section class="glass-card break-inside-avoid">
<section v-show="matchesSearch(...sectionKeywords.archiver)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Browsing</div>
@@ -448,7 +500,7 @@
</section>
<!-- Smart Crawler -->
<section class="glass-card break-inside-avoid">
<section v-show="matchesSearch(...sectionKeywords.crawler)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Discovery</div>
@@ -523,7 +575,10 @@
</section>
<!-- Appearance -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.appearance)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Personalise</div>
@@ -581,7 +636,19 @@
</section>
<!-- Language -->
<section class="glass-card break-inside-avoid">
<section
v-show="
matchesSearch(
'i18n',
'app.language',
'app.select_language',
'English',
'Deutsch',
'Русский'
)
"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">i18n</div>
@@ -599,7 +666,17 @@
</section>
<!-- Network Security -->
<section class="glass-card break-inside-avoid">
<section
v-show="
matchesSearch(
'RNS Security',
'Network Security',
'app.blackhole_integration_enabled',
'app.blackhole_integration_description'
)
"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">RNS Security</div>
@@ -633,7 +710,7 @@
</section>
<!-- Transport -->
<section class="glass-card break-inside-avoid">
<section v-show="matchesSearch(...sectionKeywords.transport)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Reticulum</div>
@@ -660,7 +737,10 @@
</section>
<!-- Interfaces -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.interfaces)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Adapters</div>
@@ -686,7 +766,10 @@
</section>
<!-- Blocked -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch('Privacy', 'Banished', 'Manage Banished users and nodes')"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
@@ -704,7 +787,7 @@
</section>
<!-- Authentication -->
<section class="glass-card break-inside-avoid">
<section v-show="matchesSearch(...sectionKeywords.auth)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Security</div>
@@ -736,7 +819,10 @@
</section>
<!-- Translator -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.translator)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">i18n</div>
@@ -778,7 +864,10 @@
</section>
<!-- Sources & Infrastructure -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.infrastructure)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Infrastructure</div>
@@ -819,7 +908,7 @@
</section>
<!-- Messages -->
<section class="glass-card break-inside-avoid">
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">{{ $t("app.reliability") }}</div>
@@ -888,7 +977,10 @@
</section>
<!-- Propagation nodes -->
<section class="glass-card break-inside-avoid">
<section
v-show="matchesSearch(...sectionKeywords.propagation)"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">LXMF</div>
@@ -1021,7 +1113,7 @@
</div>
<!-- Keyboard Shortcuts (Full width at bottom) -->
<div class="mt-4">
<div v-show="matchesSearch(...sectionKeywords.shortcuts)" class="mt-4">
<section class="glass-card">
<div class="glass-card__header">
<div class="flex items-center gap-3">
@@ -1113,9 +1205,126 @@ export default {
saveTimeouts: {},
shortcuts: [],
reloadingRns: false,
searchQuery: "",
sectionKeywords: {
banishment: [
"Visuals",
"app.banishment",
"app.banishment_description",
"app.banished_effect_enabled",
"app.banished_effect_description",
"app.banished_text_label",
"app.banished_text_description",
"app.banished_color_label",
"app.banished_color_description",
],
maintenance: [
"Maintenance",
"maintenance.title",
"maintenance.description",
"maintenance.clear_messages",
"maintenance.clear_messages_desc",
"maintenance.clear_announces",
"maintenance.clear_announces_desc",
"maintenance.clear_nomadnet_favs",
"maintenance.clear_nomadnet_favs_desc",
"maintenance.clear_archives",
"maintenance.clear_archives_desc",
"maintenance.export_messages",
"maintenance.export_messages_desc",
"maintenance.import_messages",
"maintenance.import_messages_desc",
"Automatic Backup Limit",
"Export Folders",
"Import Folders",
],
desktop: [
"Desktop",
"App Behaviour",
"app.desktop_open_calls_in_separate_window",
"app.desktop_open_calls_in_separate_window_description",
"app.desktop_hardware_acceleration_enabled",
"app.desktop_hardware_acceleration_enabled_description",
],
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
appearance: [
"Personalise",
"app.appearance",
"app.appearance_description",
"app.theme",
"app.light_theme",
"app.dark_theme",
"Message Font Size",
"app.live_preview",
"app.realtime",
],
language: ["i18n", "app.language", "app.select_language", "English", "Deutsch", "Русский"],
networkSecurity: [
"RNS Security",
"Network Security",
"app.blackhole_integration_enabled",
"app.blackhole_integration_description",
],
transport: [
"Reticulum",
"app.transport_mode",
"app.transport_description",
"app.enable_transport_mode",
"app.transport_toggle_description",
],
interfaces: [
"Adapters",
"app.interfaces",
"app.show_community_interfaces",
"app.community_interfaces_description",
],
blocked: ["Privacy", "Banished", "Manage Banished users and nodes"],
auth: ["Security", "Authentication", "password", "Protect your instance with a password"],
translator: [
"i18n",
"app.translator",
"translator.description",
"app.translator_enabled",
"app.translator_description",
"app.libretranslate_url",
"app.libretranslate_url_description",
],
infrastructure: ["Infrastructure", "Sources & Mirroring", "gitea", "documentation", "download", "urls"],
messages: [
"reliability",
"app.messages",
"app.messages_description",
"app.auto_resend_title",
"app.auto_resend_description",
"app.retry_attachments_title",
"app.retry_attachments_description",
"app.auto_fallback_title",
"app.auto_fallback_description",
"app.inbound_stamp_cost",
"app.inbound_stamp_description",
],
propagation: [
"LXMF",
"app.propagation_nodes",
"app.propagation_nodes_description",
"app.browse_nodes",
"app.run_local_node",
"app.run_local_node_description",
"app.preferred_propagation_node",
"app.auto_sync_interval",
"app.propagation_stamp_cost",
"app.propagation_stamp_description",
],
shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
},
};
},
computed: {
hasSearchResults() {
if (!this.searchQuery) return true;
return Object.values(this.sectionKeywords).some((keywords) => this.matchesSearch(...keywords));
},
safeConfig() {
if (!this.config) {
return {
@@ -1140,6 +1349,16 @@ export default {
this.getConfig();
},
methods: {
matchesSearch(...texts) {
if (!this.searchQuery) return true;
const query = this.searchQuery.toLowerCase();
return texts.some((text) => {
if (!text) return false;
// If it looks like a translation key, translate it
const content = text.includes(".") ? this.$t(text) : text;
return content.toLowerCase().includes(query);
});
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch (json.type) {
@@ -1184,11 +1403,11 @@ export default {
},
async saveShortcut(action, keys) {
await KeyboardShortcuts.saveShortcut(action, keys);
ToastUtils.success("Shortcut saved");
ToastUtils.success(this.$t("settings.shortcut_saved"));
},
async deleteShortcut(action) {
await KeyboardShortcuts.deleteShortcut(action);
ToastUtils.success("Shortcut deleted");
ToastUtils.success(this.$t("settings.shortcut_deleted"));
},
async updateConfig(config, label = null) {
try {
@@ -1198,7 +1417,7 @@ export default {
ToastUtils.success(this.$t("app.setting_auto_saved", { label: this.$t(`app.${label}`) }));
}
} catch (e) {
ToastUtils.error("Failed to save config!");
ToastUtils.error(this.$t("common.save_failed"));
console.log(e);
}
},
@@ -1498,7 +1717,7 @@ export default {
type: "nomadnet.page.archive.flush",
})
);
ToastUtils.success("Archived pages flushed.");
ToastUtils.success(this.$t("settings.archived_pages_flushed"));
},
async onIsTransportEnabledChangeWrapper(value) {
this.config.is_transport_enabled = value;
@@ -1509,13 +1728,13 @@ export default {
try {
await window.axios.post("/api/v1/reticulum/enable-transport");
} catch {
ToastUtils.error("Failed to enable transport mode!");
ToastUtils.error(this.$t("settings.failed_enable_transport"));
}
} else {
try {
await window.axios.post("/api/v1/reticulum/disable-transport");
} catch {
ToastUtils.error("Failed to disable transport mode!");
ToastUtils.error(this.$t("settings.failed_disable_transport"));
}
}
},
@@ -1527,7 +1746,7 @@ export default {
const response = await window.axios.post("/api/v1/reticulum/reload");
ToastUtils.success(response.data.message);
} catch {
ToastUtils.error("Failed to reload Reticulum!");
ToastUtils.error(this.$t("settings.failed_reload_reticulum"));
} finally {
this.reloadingRns = false;
}
@@ -1622,9 +1841,9 @@ export default {
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click();
ToastUtils.success("Folders exported");
ToastUtils.success(this.$t("settings.folders_exported"));
} catch {
ToastUtils.error("Failed to export folders");
ToastUtils.error(this.$t("settings.failed_export_folders"));
}
},
triggerFolderImport() {
@@ -1641,9 +1860,9 @@ export default {
if (!data.folders || !data.mappings) throw new Error("Invalid file format");
await window.axios.post("/api/v1/lxmf/folders/import", data);
ToastUtils.success("Folders and mappings imported successfully");
ToastUtils.success(this.$t("settings.folders_imported"));
} catch {
ToastUtils.error("Failed to import folders");
ToastUtils.error(this.$t("settings.failed_import_folders"));
}
};
reader.readAsText(file);

View File

@@ -313,7 +313,7 @@ export default {
}
} catch (err) {
console.error("Failed to render QR code:", err);
ToastUtils.error("Failed to render QR code");
ToastUtils.error(this.$t("messages.failed_render_qr"));
}
},
async ingestPaperMessage() {
@@ -330,15 +330,15 @@ export default {
try {
this.ingestUri = await navigator.clipboard.readText();
} catch {
ToastUtils.error("Failed to read from clipboard");
ToastUtils.error(this.$t("messages.failed_read_clipboard"));
}
},
async copyUri() {
try {
await navigator.clipboard.writeText(this.generatedUri);
ToastUtils.success("URI copied to clipboard");
ToastUtils.success(this.$t("messages.uri_copied"));
} catch {
ToastUtils.error("Failed to copy URI");
ToastUtils.error(this.$t("messages.failed_copy_uri"));
}
},
downloadQRCode() {
@@ -386,7 +386,7 @@ export default {
});
if (response.data.status === "success") {
ToastUtils.success("Paper message sent successfully");
ToastUtils.success(this.$t("messages.paper_message_sent"));
this.generatedUri = null;
this.destinationHash = "";
this.content = "";
@@ -396,7 +396,7 @@ export default {
}
} catch (err) {
console.error("Failed to send paper message:", err);
ToastUtils.error("Failed to send paper message");
ToastUtils.error(this.$t("messages.failed_send_paper"));
} finally {
this.isSending = false;
}

View File

@@ -374,7 +374,7 @@ export default {
this.interfaces = Object.keys(ifaceRes.data.interfaces);
} catch (e) {
console.error(e);
ToastUtils.error("Failed to fetch path data");
ToastUtils.error(this.$t("tools.rnpath.failed_fetch"));
} finally {
this.isLoading = false;
}
@@ -421,13 +421,13 @@ export default {
try {
const res = await window.axios.post("/api/v1/rnpath/drop", { destination_hash: hash });
if (res.data.success) {
ToastUtils.success("Path dropped");
ToastUtils.success(this.$t("tools.rnpath.path_dropped"));
this.refreshAll();
} else {
ToastUtils.error("Could not drop path");
ToastUtils.error(this.$t("tools.rnpath.failed_drop"));
}
} catch {
ToastUtils.error("Error dropping path");
ToastUtils.error(this.$t("tools.rnpath.error_drop"));
}
},
async requestPath() {
@@ -437,7 +437,7 @@ export default {
this.requestHash = "";
// Path requests take time, don't refresh immediately
} catch {
ToastUtils.error("Failed to request path");
ToastUtils.error(this.$t("tools.rnpath.failed_request"));
}
},
async dropAllVia() {
@@ -449,23 +449,23 @@ export default {
transport_instance_hash: this.dropViaHash,
});
if (res.data.success) {
ToastUtils.success("Paths dropped");
ToastUtils.success(this.$t("tools.rnpath.paths_dropped"));
this.dropViaHash = "";
this.refreshAll();
}
} catch {
ToastUtils.error("Failed to drop paths");
ToastUtils.error(this.$t("tools.rnpath.failed_drop_paths"));
}
},
async dropAnnounceQueues() {
if (!(await DialogUtils.confirm("Purge all announce queues? This cannot be undone."))) {
if (!(await DialogUtils.confirm(this.$t("tools.rnpath.purge_confirm")))) {
return;
}
try {
await window.axios.post("/api/v1/rnpath/drop-queues");
ToastUtils.success("Announce queues purged");
ToastUtils.success(this.$t("tools.rnpath.queues_purged"));
} catch {
ToastUtils.error("Failed to purge queues");
ToastUtils.error(this.$t("tools.rnpath.failed_purge"));
}
},
calculateRate(rate) {

View File

@@ -453,14 +453,12 @@ export default {
this.hasArgos = response.data.has_argos;
} catch (e) {
console.error(e);
DialogUtils.alert(
"Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed."
);
DialogUtils.alert(this.$t("translator.failed_load_languages"));
}
},
copyToClipboard(text) {
navigator.clipboard.writeText(text);
ToastUtils.success("Copied to clipboard");
ToastUtils.success(this.$t("common.copied"));
},
async translateText() {
if (!this.canTranslate || this.isTranslating) {
@@ -468,12 +466,12 @@ export default {
}
if (!this.sourceLang || !this.targetLang) {
this.error = "Please select both source and target languages.";
this.error = this.$t("translator.select_languages_warning");
return;
}
if (this.translationMode === "argos" && this.sourceLang === "auto") {
this.error = "Auto-detection is not supported with Argos Translate. Please select a source language.";
this.error = this.$t("translator.auto_detect_not_supported");
return;
}
@@ -499,9 +497,7 @@ export default {
}
} catch (e) {
console.error(e);
this.error =
e.response?.data?.message ||
"Translation failed. Make sure LibreTranslate is running or Argos Translate is installed.";
this.error = e.response?.data?.message || this.$t("translator.failed_translate");
} finally {
this.isTranslating = false;
}

View File

@@ -190,7 +190,10 @@
"loading_identity": "Ihre Identität wird geladen",
"emergency_mode_active": "Notfallmodus aktiv - In-Memory-Datenbank und eingeschränkte Dienste werden verwendet.",
"blackhole_integration_enabled": "Blackhole-Integration",
"blackhole_integration_description": "Identitäten automatisch auf der Reticulum-Transportebene sperren (Blackhole), wenn Benutzer in MeshChatX verbannt werden."
"blackhole_integration_description": "Identitäten automatisch auf der Reticulum-Transportebene sperren (Blackhole), wenn Benutzer in MeshChatX verbannt werden.",
"failed_announce": "failed to announce",
"search_settings": "Search settings...",
"show_qr": "Show QR Code"
},
"common": {
"open": "Öffnen",
@@ -213,7 +216,19 @@
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"search": "Werkzeuge suchen...",
"no_results": "Keine Werkzeuge gefunden",
"error": "Fehler"
"error": "Fehler",
"continue": "Continue",
"success": "Success",
"warning": "Warning",
"info": "Info",
"copied": "Copied to clipboard",
"failed_to_copy": "Failed to copy to clipboard",
"save_failed": "Failed to save configuration!",
"invalid_address": "Invalid Address",
"loading": "Loading...",
"ok": "OK",
"copy": "Copy",
"copy_to_clipboard": "Copy to clipboard"
},
"maintenance": {
"title": "Wartung & Daten",
@@ -254,7 +269,13 @@
"delete_confirm": "Sind Sie sicher, dass Sie die Identität \"{name}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"switched": "Identität gewechselt. Die Anwendung wird jetzt neu gestartet, um die Änderungen zu übernehmen.",
"created": "Identität erfolgreich erstellt",
"deleted": "Identität gelöscht"
"deleted": "Identität gelöscht",
"failed_load": "Failed to load identities",
"enter_display_name_warning": "Please enter a display name",
"failed_create": "Failed to create identity",
"switch_scheduled": "Switch scheduled. Reloading application...",
"failed_switch": "Failed to switch identity",
"failed_delete": "Failed to delete identity"
},
"about": {
"title": "Über",
@@ -305,7 +326,26 @@
"no_integrity_violations": "Keine Integritätsverletzungen seit dem letzten Start erkannt.",
"dependency_chain": "Abhängigkeitskette",
"other_core_components": "Andere Kernkomponenten",
"backend_dependencies": "Backend-Abhängigkeiten"
"backend_dependencies": "Backend-Abhängigkeiten",
"integrity_backend_error": "The application backend binary (unpacked from ASAR) appears to have been modified or replaced. This could indicate a malicious actor trying to compromise your mesh communication.",
"integrity_data_error": "Your identities or database files appear to have been modified while the app was closed.",
"integrity_warning_footer": "Proceed with caution. If you did not manually update or modify these files, your installation may be compromised.",
"delete_snapshot_confirm": "Are you sure you want to delete this snapshot?",
"snapshot_deleted": "Snapshot deleted",
"failed_delete_snapshot": "Failed to delete snapshot",
"delete_backup_confirm": "Are you sure you want to delete this backup?",
"backup_deleted": "Backup deleted",
"failed_delete_backup": "Failed to delete backup",
"database_restored": "Database restored. Relaunching...",
"failed_restore_snapshot": "Failed to restore snapshot",
"restore_snapshot_confirm": "Are you sure you want to restore this snapshot? This will overwrite the current database and require an app relaunch.",
"integrity_acknowledged": "Integrity issues acknowledged",
"integrity_acknowledged_reset": "Integrity issues acknowledged and manifest reset",
"integrity_acknowledge_confirm": "Are you sure you want to acknowledge these integrity issues? This will update the security manifest to match the current state of your files.",
"failed_acknowledge_integrity": "Failed to acknowledge integrity issues",
"shutdown_sent": "Shutdown command sent to server.",
"identity_exported": "Identity key file exported",
"identity_copied": "Identity Base32 key copied to clipboard"
},
"interfaces": {
"title": "Schnittstellen",
@@ -321,7 +361,23 @@
"no_interfaces_description": "Passen Sie Ihre Suche an oder fügen Sie eine neue Schnittstelle hinzu.",
"restart_required": "Neustart erforderlich",
"restart_description": "Reticulum MeshChatX muss neu gestartet werden, damit Schnittstellenänderungen wirksam werden.",
"restart_now": "Jetzt neu starten"
"restart_now": "Jetzt neu starten",
"failed_enable": "failed to enable interface",
"failed_disable": "failed to disable interface",
"delete_confirm": "Are you sure you want to delete this interface? This can not be undone!",
"failed_delete": "failed to delete interface",
"failed_export_all": "Failed to export interfaces",
"failed_export_single": "Failed to export interface",
"failed_reload": "Failed to reload Reticulum!",
"interface_not_found": "The selected interface for editing could not be found.",
"discovery_settings_saved": "Discovery settings saved",
"failed_save_discovery": "Failed to save discovery settings",
"no_interfaces_found_config": "No interfaces were found in the selected configuration file",
"failed_parse_config": "Failed to parse configuration file",
"select_config_file": "Please select a configuration file",
"select_at_least_one": "Please select at least one interface to import",
"import_success": "Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.",
"failed_import_all": "Failed to import interfaces"
},
"map": {
"title": "Karte",
@@ -390,7 +446,33 @@
"no_drawings_desc": "Sie haben noch keine Kartenanmerkungen gespeichert.",
"saved_on": "Gespeichert am",
"settings": "Karteneinstellungen",
"go_to_my_location": "Mein Standort"
"go_to_my_location": "Mein Standort",
"source_updated": "Map source updated",
"failed_set_active": "Failed to set active map",
"file_deleted": "File deleted",
"failed_delete_file": "Failed to delete file",
"storage_saved": "Storage directory saved",
"failed_save_storage": "Failed to save directory",
"export_cancelled": "Export cancelled",
"failed_cancel_export": "Failed to cancel export",
"failed_start_export": "Failed to start export",
"select_mbtiles_error": "Please select an .mbtiles file",
"view_saved": "Default view saved",
"failed_save_view": "Failed to save default view",
"failed_clear_cache": "Failed to clear cache",
"failed_save_tile_server": "Failed to save tile server URL",
"failed_save_nominatim": "Failed to save Nominatim API URL",
"copied_coordinates": "Copied coordinates",
"failed_load_drawings": "Failed to load drawings",
"not_initialized": "Map not initialized",
"drawing_saved": "Drawing saved",
"failed_save_drawing": "Failed to save drawing",
"deleted": "Deleted",
"failed_delete": "Failed to delete",
"location_not_determined": "Could not determine your location",
"geolocation_not_supported": "Geolocation is not supported by your browser",
"no_nodes_location": "No discovered nodes with location found",
"failed_fetch_nodes": "Failed to fetch discovered nodes for mapping"
},
"interface": {
"disable": "Deaktivieren",
@@ -454,7 +536,74 @@
"generate_paper_message": "Papier-Nachricht erstellen (LXM)",
"recording": "Aufnahme: {duration}",
"nomad_network_node": "Nomad Network Knoten",
"toggle_source": "Quellcode umschalten"
"toggle_source": "Quellcode umschalten",
"folder_created": "Folder created",
"failed_create_folder": "Failed to create folder",
"folder_renamed": "Folder renamed",
"failed_rename_folder": "Failed to rename folder",
"folder_deleted": "Folder deleted",
"failed_delete_folder": "Failed to delete folder",
"moved_to_folder": "Moved to folder",
"failed_move_folder": "Failed to move to folder",
"marked_read": "Marked as read",
"failed_mark_read": "Failed to mark as read",
"conversations_deleted": "Conversations deleted",
"failed_delete_conversations": "Failed to delete conversations",
"failed_export_folders": "Failed to export folders",
"folders_imported": "Folders imported",
"failed_import_folders": "Failed to import folders",
"failed_read_clipboard": "Failed to read from clipboard",
"failed_send_ingest": "Failed to send ingest request",
"address_copied": "Your LXMF address copied to clipboard",
"failed_copy_address": "Failed to copy address",
"translation_failed": "Translation failed",
"failed_add_contact": "Failed to add contact",
"ingesting_paper_message": "Ingesting paper message...",
"failed_ingest_paper": "Failed to ingest paper message",
"enter_display_name": "Enter a custom display name",
"failed_update_display_name": "Failed to update display name",
"failed_load_audio": "Failed to load audio attachment.",
"no_contacts_telephone": "No contacts found in telephone",
"failed_load_contacts": "Failed to load contacts",
"location_request_sent": "Location request sent",
"failed_send_location_request": "Failed to send location request",
"remove_image_confirm": "Are you sure you want to remove this image attachment?",
"failed_start_recording": "failed to start recording",
"remove_audio_confirm": "Are you sure you want to remove this audio attachment?",
"failed_generate_qr": "Failed to generate QR",
"enter_folder_name": "Enter folder name",
"new_folder": "New Folder",
"rename_folder": "Rename folder",
"failed_render_qr": "Failed to render QR code",
"uri_copied": "URI copied to clipboard",
"failed_copy_uri": "Failed to copy URI",
"paper_message_sent": "Paper message sent successfully",
"failed_send_paper": "Failed to send paper message",
"failed_load_config": "Failed to load configuration",
"profile_icon_saved": "Profile icon saved successfully",
"failed_save_profile_icon": "Failed to save profile icon",
"select_colors_warning": "Please select both background and icon colors",
"select_icon_warning": "Please select an icon",
"changes_reset": "Changes reset to saved values",
"profile_icon_removed": "Profile icon removed successfully",
"user_banished": "User banished successfully",
"failed_banish_user": "Failed to banish user",
"delete_conversation_confirm": "Are you sure you want to delete this conversation?",
"failed_delete_conversation": "failed to delete conversation",
"delete_history_confirm": "Are you sure you want to delete all messages in this conversation? This can not be undone!",
"failed_delete_history": "failed to delete history",
"invalid_destination_hash": "Invalid destination hash",
"invalid_destination_hash_format": "Invalid destination hash format",
"ping_failed": "Ping failed. Try again later",
"ping_reply_from": "Valid reply from {hash}",
"duration": "Duration: {duration}",
"hops_there": "Hops There: {count}",
"hops_back": "Hops Back: {count}",
"signal_quality": "Signal Quality: {quality}%",
"rssi_val": "RSSI: {rssi}dBm",
"snr_val": "SNR: {snr}dB",
"hash_copied": "Identity hash copied to clipboard",
"failed_to_copy_hash": "Failed to copy identity hash"
},
"nomadnet": {
"remove_favourite": "Favorit entfernen",
@@ -494,7 +643,16 @@
"enter_nomadnet_url": "Nomadnet-URL eingeben",
"archiving_page": "Seite wird archiviert...",
"page_archived_successfully": "Seite erfolgreich archiviert.",
"identify_confirm": "Sind Sie sicher, dass Sie sich gegenüber diesem NomadNetwork-Knoten identifizieren möchten? Die Seite wird nach dem Senden Ihrer Identität neu geladen."
"identify_confirm": "Sind Sie sicher, dass Sie sich gegenüber diesem NomadNetwork-Knoten identifizieren möchten? Die Seite wird nach dem Senden Ihrer Identität neu geladen.",
"failed_rename_favourite": "Failed to rename favourite",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment",
"enter_section_name": "Enter section name",
"new_section": "New Section",
"rename_section": "Rename section",
"delete_section_confirm": "Delete this section? Favourites will move to the main section.",
"unsupported_url": "unsupported url: ",
"no_search_results_peers": "No peers found matching your search."
},
"forwarder": {
"title": "LXMF-Weiterleiter",
@@ -537,7 +695,10 @@
"status_not_downloaded": "Nicht heruntergeladen",
"btn_download": "Handbücher herunterladen",
"btn_update": "Handbuch aktualisieren",
"error": "Fehler"
"error": "Fehler",
"failed_update_docs": "Failed to update documentation sources",
"docs_link_copied": "Documentation link copied to clipboard",
"failed_copy_link": "Failed to copy link"
},
"tools": {
"utilities": "Dienstprogramme",
@@ -561,7 +722,17 @@
},
"rnpath": {
"title": "RNPath",
"description": "Verwalten Sie die Pfadtabelle, sehen Sie Ankündigungsraten und fordern Sie Pfade an."
"description": "Verwalten Sie die Pfadtabelle, sehen Sie Ankündigungsraten und fordern Sie Pfade an.",
"failed_fetch": "Failed to fetch path data",
"path_dropped": "Path dropped",
"failed_drop": "Could not drop path",
"error_drop": "Error dropping path",
"failed_request": "Failed to request path",
"paths_dropped": "Paths dropped",
"failed_drop_paths": "Failed to drop paths",
"purge_confirm": "Purge all announce queues? This cannot be undone.",
"queues_purged": "Announce queues purged",
"failed_purge": "Failed to purge queues"
},
"translator": {
"title": "Übersetzer",
@@ -890,8 +1061,11 @@
"available_languages": "Verfügbare Sprachen",
"languages_loaded_from": "Sprachen werden von der LibreTranslate-API oder Argos Translate-Paketen geladen.",
"refresh_languages": "Sprachen aktualisieren",
"failed_to_load_languages": "Sprachen konnten nicht geladen werden. Stellen Sie sicher, dass LibreTranslate ausgeführt wird oder Argos Translate installiert ist.",
"copied_to_clipboard": "In die Zwischenablage kopiert"
"copied_to_clipboard": "In die Zwischenablage kopiert",
"failed_load_languages": "Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed.",
"failed_translate": "Translation failed. Make sure LibreTranslate is running or Argos Translate is installed.",
"select_languages_warning": "Please select both source and target languages.",
"auto_detect_not_supported": "Auto-detection is not supported with Argos Translate. Please select a source language."
},
"call": {
"phone": "Telefon",
@@ -1012,7 +1186,36 @@
"failed_to_set_primary_ringtone": "Primärer Klingelton konnte nicht festgelegt werden",
"failed_to_update_ringtone_name": "Klingeltonname konnte nicht aktualisiert werden",
"failed_to_play_ringtone": "Klingelton konnte nicht abgespielt werden",
"failed_to_play_voicemail": "Sprachnachricht konnte nicht abgespielt werden"
"failed_to_play_voicemail": "Sprachnachricht konnte nicht abgespielt werden",
"web_audio_not_available": "Web audio not available",
"failed_to_update_dnd": "Failed to update Do Not Disturb status",
"failed_to_update_call_settings": "Failed to update call settings",
"failed_to_update_recording_status": "Failed to update call recording status",
"call_history_cleared": "Call history cleared",
"failed_to_clear_call_history": "Failed to clear call history",
"identity_banished": "Identity banished",
"failed_to_banish_identity": "Failed to banish identity",
"name_and_hash_required": "Name and identity hash required",
"contact_updated": "Contact updated",
"contact_added": "Contact added",
"contact_deleted": "Contact deleted",
"failed_to_delete_contact": "Failed to delete contact",
"hash_copied": "Hash copied to clipboard",
"failed_to_copy_hash": "Failed to copy hash",
"greeting_uploaded_successfully": "Greeting uploaded successfully",
"greeting_deleted": "Greeting deleted",
"failed_to_delete_greeting": "Failed to delete greeting",
"failed_to_start_recording_greeting": "Failed to start recording greeting",
"greeting_recorded_from_mic": "Greeting recorded from mic",
"failed_to_stop_recording_greeting": "Failed to stop recording greeting",
"failed_to_load_recording": "Failed to load recording audio",
"recording_deleted": "Recording deleted",
"failed_to_delete_recording": "Failed to delete recording",
"call_sent_to_voicemail": "Call sent to voicemail",
"failed_to_send_to_voicemail": "Failed to send call to voicemail",
"failed_load_audio_edit": "Failed to load audio for editing",
"ringtone_saved": "Ringtone saved successfully",
"failed_save_ringtone": "Failed to save edited ringtone"
},
"tutorial": {
"title": "Erste Schritte",
@@ -1091,7 +1294,9 @@
"discovery_question": "Möchten Sie die Community-Schnittstellenerkennung und Auto-Connect verwenden?",
"discovery_desc": "Dies ermöglicht es MeshChatX, automatisch öffentliche Community-Knoten in Ihrer Nähe oder im Internet zu finden und sich mit ihnen zu verbinden.",
"yes": "Ja, Erkennung verwenden",
"no": "Nein, manuelle Einrichtung"
"no": "Nein, manuelle Einrichtung",
"discovery_enabled": "Community discovery enabled",
"failed_enable_discovery": "Failed to enable discovery"
},
"command_palette": {
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",
@@ -1144,6 +1349,97 @@
"action_getting_started": "Erste Schritte",
"action_getting_started_desc": "Einführungsleitfaden anzeigen",
"action_changelog": "Änderungsprotokoll",
"action_changelog_desc": "Was ist neu anzeigen"
"action_changelog_desc": "Was ist neu anzeigen",
"action_bouncing_balls": "Bouncing Balls",
"action_bouncing_balls_desc": "Toggle bouncing balls mode"
},
"settings": {
"shortcut_saved": "Shortcut saved",
"shortcut_deleted": "Shortcut deleted",
"archived_pages_flushed": "Archived pages flushed.",
"failed_enable_transport": "Failed to enable transport mode!",
"failed_disable_transport": "Failed to disable transport mode!",
"failed_reload_reticulum": "Failed to reload Reticulum!",
"folders_exported": "Folders exported",
"failed_export_folders": "Failed to export folders",
"folders_imported": "Folders and mappings imported successfully",
"failed_import_folders": "Failed to import folders"
},
"debug": {
"title": "Debug Logs",
"description": "View application logs and detected anomalies.",
"failed_fetch_logs": "Failed to fetch logs",
"logs_copied": "Logs on this page copied to clipboard",
"failed_copy_logs": "Failed to copy logs"
},
"visualiser": {
"title": "Network Visualiser",
"description": "Visualize the mesh network topology.",
"reticulum_mesh": "Reticulum Mesh",
"network_visualizer": "Network Visualizer",
"node_info": "Node Info",
"hash": "Hash",
"hops": "Hops",
"interface": "Interface",
"rssi": "RSSI",
"snr": "SNR",
"first_seen": "First seen",
"last_seen": "Last seen",
"center_node": "Center on node",
"copy_hash": "Copy Hash",
"banish_identity": "Banish identity",
"banished_identities": "Banished identities",
"filter_nodes": "Filter nodes...",
"all": "All",
"direct": "Direct",
"one_hop": "1 Hop",
"multi_hops": "2+ Hops",
"clear": "Clear",
"show_labels": "Show all labels",
"bouncing_balls": "Bouncing balls mode",
"orbit_mode": "Orbit mode",
"show_edges": "Show edges",
"show_tooltips": "Show tooltips",
"show_rssi": "Show RSSI",
"show_snr": "Show SNR",
"show_hops": "Show Hops",
"physics": "Physics",
"gravity": "Gravity",
"spring_length": "Spring length",
"spring_strength": "Spring strength",
"damping": "Damping",
"central_gravity": "Central gravity",
"edge_strength": "Edge strength",
"force_atlas_2": "Force Atlas 2",
"barns_hut": "Barns Hut",
"repulsion": "Repulsion",
"reset_physics": "Reset physics",
"reset_camera": "Reset Camera",
"clear_graph": "Clear graph",
"total_nodes": "Total nodes",
"total_edges": "Total edges",
"discovered": "Discovered",
"wait_between_batches": "Wait between batches",
"batch": "Batch",
"loading_status": "Loading status",
"failed_load": "Failed to load network data"
},
"banishment": {
"title": "Banished",
"description": "Manage Banished users and nodes",
"failed_load_banished": "Failed to load banished destinations",
"search_placeholder": "Search by hash or display name...",
"loading_items": "Loading banished items...",
"no_items": "No banished items found",
"lift_banishment": "Lift Banishment",
"lift_banishment_confirm": "Are you sure you want to lift the banishment for {name}?",
"banished_at": "Banished at",
"banished_until": "Banished until",
"reason": "Reason",
"type": "Type",
"user": "User",
"node": "Node",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment"
}
}

View File

@@ -56,6 +56,8 @@
"lxmf_address": "LXMF Address",
"announce": "Announce",
"announce_now": "Announce Now",
"show_qr": "Show QR Code",
"failed_announce": "failed to announce",
"last_announced": "Last announced: {time}",
"last_announced_never": "Last announced: Never",
"display_name_placeholder": "Display Name",
@@ -123,7 +125,7 @@
"not_synced": "Not Synced",
"not_configured": "Not Configured",
"toggle_source": "Toggle Source Code",
"audio_calls": "Telephone",
"audio_calls": "Audio Calls",
"calls": "Calls",
"status": "Status",
"active_call": "Active Call",
@@ -145,6 +147,7 @@
"propagation_node": "Propagation Node",
"sync_now": "Sync Now",
"setting_auto_saved": "Setting {label} auto-saved",
"search_settings": "Search settings...",
"auto_resend": "Auto Resend",
"retry_attachments": "Retry Attachments",
"auto_fallback": "Auto Fallback",
@@ -205,15 +208,27 @@
"restart_app": "Restart App",
"reveal": "Reveal",
"refresh": "Refresh",
"copy": "Copy",
"copy_to_clipboard": "Copy to clipboard",
"vacuum": "Vacuum",
"auto_recover": "Auto Recover",
"shutdown": "Shutdown",
"acknowledge_reset": "Acknowledge & Reset",
"continue": "Continue",
"confirm": "Confirm",
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
"search": "Search tools...",
"no_results": "No tools found",
"error": "Error"
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"copied": "Copied to clipboard",
"failed_to_copy": "Failed to copy to clipboard",
"save_failed": "Failed to save configuration!",
"invalid_address": "Invalid Address",
"loading": "Loading...",
"ok": "OK"
},
"maintenance": {
"title": "Maintenance & Data",
@@ -254,7 +269,13 @@
"delete_confirm": "Are you sure you want to delete identity \"{name}\"? This cannot be undone.",
"switched": "Identity switched. The application will now restart to apply changes.",
"created": "Identity created successfully",
"deleted": "Identity deleted"
"deleted": "Identity deleted",
"failed_load": "Failed to load identities",
"enter_display_name_warning": "Please enter a display name",
"failed_create": "Failed to create identity",
"switch_scheduled": "Switch scheduled. Reloading application...",
"failed_switch": "Failed to switch identity",
"failed_delete": "Failed to delete identity"
},
"about": {
"title": "About",
@@ -302,10 +323,29 @@
"secured": "Secured",
"tampering_detected": "Tampering Detected",
"technical_issues": "Technical Issues:",
"integrity_backend_error": "The application backend binary (unpacked from ASAR) appears to have been modified or replaced. This could indicate a malicious actor trying to compromise your mesh communication.",
"integrity_data_error": "Your identities or database files appear to have been modified while the app was closed.",
"integrity_warning_footer": "Proceed with caution. If you did not manually update or modify these files, your installation may be compromised.",
"no_integrity_violations": "No integrity violations detected since last startup.",
"dependency_chain": "Dependency Chain",
"other_core_components": "Other Core Components",
"backend_dependencies": "Backend Dependencies"
"backend_dependencies": "Backend Dependencies",
"delete_snapshot_confirm": "Are you sure you want to delete this snapshot?",
"snapshot_deleted": "Snapshot deleted",
"failed_delete_snapshot": "Failed to delete snapshot",
"delete_backup_confirm": "Are you sure you want to delete this backup?",
"backup_deleted": "Backup deleted",
"failed_delete_backup": "Failed to delete backup",
"database_restored": "Database restored. Relaunching...",
"failed_restore_snapshot": "Failed to restore snapshot",
"restore_snapshot_confirm": "Are you sure you want to restore this snapshot? This will overwrite the current database and require an app relaunch.",
"integrity_acknowledged": "Integrity issues acknowledged",
"integrity_acknowledged_reset": "Integrity issues acknowledged and manifest reset",
"integrity_acknowledge_confirm": "Are you sure you want to acknowledge these integrity issues? This will update the security manifest to match the current state of your files.",
"failed_acknowledge_integrity": "Failed to acknowledge integrity issues",
"shutdown_sent": "Shutdown command sent to server.",
"identity_exported": "Identity key file exported",
"identity_copied": "Identity Base32 key copied to clipboard"
},
"interfaces": {
"title": "Interfaces",
@@ -321,7 +361,23 @@
"no_interfaces_description": "Adjust your search or add a new interface.",
"restart_required": "Restart required",
"restart_description": "Reticulum MeshChatX must be restarted for any interface changes to take effect.",
"restart_now": "Restart now"
"restart_now": "Restart now",
"failed_enable": "failed to enable interface",
"failed_disable": "failed to disable interface",
"delete_confirm": "Are you sure you want to delete this interface? This can not be undone!",
"failed_delete": "failed to delete interface",
"failed_export_all": "Failed to export interfaces",
"failed_export_single": "Failed to export interface",
"failed_reload": "Failed to reload Reticulum!",
"interface_not_found": "The selected interface for editing could not be found.",
"discovery_settings_saved": "Discovery settings saved",
"failed_save_discovery": "Failed to save discovery settings",
"no_interfaces_found_config": "No interfaces were found in the selected configuration file",
"failed_parse_config": "Failed to parse configuration file",
"select_config_file": "Please select a configuration file",
"select_at_least_one": "Please select at least one interface to import",
"import_success": "Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.",
"failed_import_all": "Failed to import interfaces"
},
"map": {
"title": "Map",
@@ -390,7 +446,33 @@
"no_drawings_desc": "You haven't saved any map annotations yet.",
"saved_on": "Saved on",
"settings": "Map Settings",
"go_to_my_location": "Go to My Location"
"go_to_my_location": "Go to My Location",
"source_updated": "Map source updated",
"failed_set_active": "Failed to set active map",
"file_deleted": "File deleted",
"failed_delete_file": "Failed to delete file",
"storage_saved": "Storage directory saved",
"failed_save_storage": "Failed to save directory",
"export_cancelled": "Export cancelled",
"failed_cancel_export": "Failed to cancel export",
"failed_start_export": "Failed to start export",
"select_mbtiles_error": "Please select an .mbtiles file",
"view_saved": "Default view saved",
"failed_save_view": "Failed to save default view",
"failed_clear_cache": "Failed to clear cache",
"failed_save_tile_server": "Failed to save tile server URL",
"failed_save_nominatim": "Failed to save Nominatim API URL",
"copied_coordinates": "Copied coordinates",
"failed_load_drawings": "Failed to load drawings",
"not_initialized": "Map not initialized",
"drawing_saved": "Drawing saved",
"failed_save_drawing": "Failed to save drawing",
"deleted": "Deleted",
"failed_delete": "Failed to delete",
"location_not_determined": "Could not determine your location",
"geolocation_not_supported": "Geolocation is not supported by your browser",
"no_nodes_location": "No discovered nodes with location found",
"failed_fetch_nodes": "Failed to fetch discovered nodes for mapping"
},
"interface": {
"disable": "Disable",
@@ -454,7 +536,163 @@
"generate_paper_message": "Generate Paper Message (LXM)",
"recording": "Recording: {duration}",
"nomad_network_node": "Nomad Network Node",
"toggle_source": "Toggle Source Code"
"toggle_source": "Toggle Source Code",
"folder_created": "Folder created",
"failed_create_folder": "Failed to create folder",
"folder_renamed": "Folder renamed",
"failed_rename_folder": "Failed to rename folder",
"folder_deleted": "Folder deleted",
"failed_delete_folder": "Failed to delete folder",
"moved_to_folder": "Moved to folder",
"failed_move_folder": "Failed to move to folder",
"marked_read": "Marked as read",
"failed_mark_read": "Failed to mark as read",
"conversations_deleted": "Conversations deleted",
"failed_delete_conversations": "Failed to delete conversations",
"failed_export_folders": "Failed to export folders",
"folders_imported": "Folders imported",
"failed_import_folders": "Failed to import folders",
"failed_read_clipboard": "Failed to read from clipboard",
"failed_send_ingest": "Failed to send ingest request",
"address_copied": "Your LXMF address copied to clipboard",
"hash_copied": "Identity hash copied to clipboard",
"failed_to_copy_hash": "Failed to copy identity hash",
"failed_copy_address": "Failed to copy address",
"translation_failed": "Translation failed",
"failed_add_contact": "Failed to add contact",
"ingesting_paper_message": "Ingesting paper message...",
"failed_ingest_paper": "Failed to ingest paper message",
"enter_display_name": "Enter a custom display name",
"failed_update_display_name": "Failed to update display name",
"failed_load_audio": "Failed to load audio attachment.",
"no_contacts_telephone": "No contacts found in telephone",
"failed_load_contacts": "Failed to load contacts",
"location_request_sent": "Location request sent",
"failed_send_location_request": "Failed to send location request",
"remove_image_confirm": "Are you sure you want to remove this image attachment?",
"failed_start_recording": "failed to start recording",
"remove_audio_confirm": "Are you sure you want to remove this audio attachment?",
"failed_generate_qr": "Failed to generate QR",
"enter_folder_name": "Enter folder name",
"new_folder": "New Folder",
"rename_folder": "Rename folder",
"failed_render_qr": "Failed to render QR code",
"uri_copied": "URI copied to clipboard",
"failed_copy_uri": "Failed to copy URI",
"paper_message_sent": "Paper message sent successfully",
"failed_send_paper": "Failed to send paper message",
"failed_load_config": "Failed to load configuration",
"profile_icon_saved": "Profile icon saved successfully",
"failed_save_profile_icon": "Failed to save profile icon",
"select_colors_warning": "Please select both background and icon colors",
"select_icon_warning": "Please select an icon",
"changes_reset": "Changes reset to saved values",
"profile_icon_removed": "Profile icon removed successfully",
"user_banished": "User banished successfully",
"failed_banish_user": "Failed to banish user",
"delete_conversation_confirm": "Are you sure you want to delete this conversation?",
"failed_delete_conversation": "failed to delete conversation",
"delete_history_confirm": "Are you sure you want to delete all messages in this conversation? This can not be undone!",
"failed_delete_history": "failed to delete history",
"invalid_destination_hash": "Invalid destination hash",
"invalid_destination_hash_format": "Invalid destination hash format",
"ping_failed": "Ping failed. Try again later",
"ping_reply_from": "Valid reply from {hash}",
"duration": "Duration: {duration}",
"hops_there": "Hops There: {count}",
"hops_back": "Hops Back: {count}",
"signal_quality": "Signal Quality: {quality}%",
"rssi_val": "RSSI: {rssi}dBm",
"snr_val": "SNR: {snr}dB"
},
"settings": {
"shortcut_saved": "Shortcut saved",
"shortcut_deleted": "Shortcut deleted",
"archived_pages_flushed": "Archived pages flushed.",
"failed_enable_transport": "Failed to enable transport mode!",
"failed_disable_transport": "Failed to disable transport mode!",
"failed_reload_reticulum": "Failed to reload Reticulum!",
"folders_exported": "Folders exported",
"failed_export_folders": "Failed to export folders",
"folders_imported": "Folders and mappings imported successfully",
"failed_import_folders": "Failed to import folders"
},
"debug": {
"title": "Debug Logs",
"description": "View application logs and detected anomalies.",
"failed_fetch_logs": "Failed to fetch logs",
"logs_copied": "Logs on this page copied to clipboard",
"failed_copy_logs": "Failed to copy logs"
},
"visualiser": {
"title": "Network Visualiser",
"description": "Visualize the mesh network topology.",
"reticulum_mesh": "Reticulum Mesh",
"network_visualizer": "Network Visualizer",
"node_info": "Node Info",
"hash": "Hash",
"hops": "Hops",
"interface": "Interface",
"rssi": "RSSI",
"snr": "SNR",
"first_seen": "First seen",
"last_seen": "Last seen",
"center_node": "Center on node",
"copy_hash": "Copy Hash",
"banish_identity": "Banish identity",
"banished_identities": "Banished identities",
"filter_nodes": "Filter nodes...",
"all": "All",
"direct": "Direct",
"one_hop": "1 Hop",
"multi_hops": "2+ Hops",
"clear": "Clear",
"show_labels": "Show all labels",
"bouncing_balls": "Bouncing balls mode",
"orbit_mode": "Orbit mode",
"show_edges": "Show edges",
"show_tooltips": "Show tooltips",
"show_rssi": "Show RSSI",
"show_snr": "Show SNR",
"show_hops": "Show Hops",
"physics": "Physics",
"gravity": "Gravity",
"spring_length": "Spring length",
"spring_strength": "Spring strength",
"damping": "Damping",
"central_gravity": "Central gravity",
"edge_strength": "Edge strength",
"force_atlas_2": "Force Atlas 2",
"barns_hut": "Barns Hut",
"repulsion": "Repulsion",
"reset_physics": "Reset physics",
"reset_camera": "Reset Camera",
"clear_graph": "Clear graph",
"total_nodes": "Total nodes",
"total_edges": "Total edges",
"discovered": "Discovered",
"wait_between_batches": "Wait between batches",
"batch": "Batch",
"loading_status": "Loading status",
"failed_load": "Failed to load network data"
},
"banishment": {
"title": "Banished",
"description": "Manage Banished users and nodes",
"failed_load_banished": "Failed to load banished destinations",
"search_placeholder": "Search by hash or display name...",
"loading_items": "Loading banished items...",
"no_items": "No banished items found",
"lift_banishment": "Lift Banishment",
"lift_banishment_confirm": "Are you sure you want to lift the banishment for {name}?",
"banished_at": "Banished at",
"banished_until": "Banished until",
"reason": "Reason",
"type": "Type",
"user": "User",
"node": "Node",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment"
},
"nomadnet": {
"remove_favourite": "Remove Favourite",
@@ -485,16 +723,25 @@
"announced_time_ago": "Announced {time} ago",
"block_node": "Banish Node",
"no_announces_yet": "No announces yet",
"no_search_results_peers": "No peers found matching your search.",
"listening_for_peers": "Listening for peers on the mesh.",
"block_node_confirm": "Are you sure you want to banish {name}? Their announces will be ignored and they won't appear in the announce stream.",
"node_blocked_successfully": "Node banished successfully",
"failed_to_block_node": "Failed to banish node",
"failed_rename_favourite": "Failed to rename favourite",
"rename_favourite": "Rename this favourite",
"remove_favourite_confirm": "Are you sure you want to remove this favourite?",
"enter_nomadnet_url": "Enter a Nomadnet URL",
"archiving_page": "Archiving page...",
"page_archived_successfully": "Page archived successfully.",
"identify_confirm": "Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent."
"identify_confirm": "Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment",
"enter_section_name": "Enter section name",
"new_section": "New Section",
"rename_section": "Rename section",
"delete_section_confirm": "Delete this section? Favourites will move to the main section.",
"unsupported_url": "unsupported url: "
},
"forwarder": {
"title": "LXMF Forwarder",
@@ -537,7 +784,10 @@
"status_not_downloaded": "Not Downloaded",
"btn_download": "Download Manuals",
"btn_update": "Update Manual",
"error": "Error"
"error": "Error",
"failed_update_docs": "Failed to update documentation sources",
"docs_link_copied": "Documentation link copied to clipboard",
"failed_copy_link": "Failed to copy link"
},
"tools": {
"utilities": "Utilities",
@@ -561,7 +811,17 @@
},
"rnpath": {
"title": "RNPath",
"description": "Manage the path table, view announce rates and request paths."
"description": "Manage the path table, view announce rates and request paths.",
"failed_fetch": "Failed to fetch path data",
"path_dropped": "Path dropped",
"failed_drop": "Could not drop path",
"error_drop": "Error dropping path",
"failed_request": "Failed to request path",
"paths_dropped": "Paths dropped",
"failed_drop_paths": "Failed to drop paths",
"purge_confirm": "Purge all announce queues? This cannot be undone.",
"queues_purged": "Announce queues purged",
"failed_purge": "Failed to purge queues"
},
"translator": {
"title": "Translator",
@@ -890,7 +1150,10 @@
"available_languages": "Available Languages",
"languages_loaded_from": "Languages are loaded from LibreTranslate API or Argos Translate packages.",
"refresh_languages": "Refresh Languages",
"failed_to_load_languages": "Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed.",
"failed_load_languages": "Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed.",
"failed_translate": "Translation failed. Make sure LibreTranslate is running or Argos Translate is installed.",
"select_languages_warning": "Please select both source and target languages.",
"auto_detect_not_supported": "Auto-detection is not supported with Argos Translate. Please select a source language.",
"copied_to_clipboard": "Copied to clipboard"
},
"call": {
@@ -1012,7 +1275,36 @@
"failed_to_set_primary_ringtone": "Failed to set primary ringtone",
"failed_to_update_ringtone_name": "Failed to update ringtone name",
"failed_to_play_ringtone": "Failed to play ringtone",
"failed_to_play_voicemail": "Failed to play voicemail"
"failed_to_play_voicemail": "Failed to play voicemail",
"web_audio_not_available": "Web audio not available",
"failed_to_update_dnd": "Failed to update Do Not Disturb status",
"failed_to_update_call_settings": "Failed to update call settings",
"failed_to_update_recording_status": "Failed to update call recording status",
"call_history_cleared": "Call history cleared",
"failed_to_clear_call_history": "Failed to clear call history",
"identity_banished": "Identity banished",
"failed_to_banish_identity": "Failed to banish identity",
"name_and_hash_required": "Name and identity hash required",
"contact_updated": "Contact updated",
"contact_added": "Contact added",
"contact_deleted": "Contact deleted",
"failed_to_delete_contact": "Failed to delete contact",
"hash_copied": "Hash copied to clipboard",
"failed_to_copy_hash": "Failed to copy hash",
"greeting_uploaded_successfully": "Greeting uploaded successfully",
"greeting_deleted": "Greeting deleted",
"failed_to_delete_greeting": "Failed to delete greeting",
"failed_to_start_recording_greeting": "Failed to start recording greeting",
"greeting_recorded_from_mic": "Greeting recorded from mic",
"failed_to_stop_recording_greeting": "Failed to stop recording greeting",
"failed_to_load_recording": "Failed to load recording audio",
"recording_deleted": "Recording deleted",
"failed_to_delete_recording": "Failed to delete recording",
"call_sent_to_voicemail": "Call sent to voicemail",
"failed_to_send_to_voicemail": "Failed to send call to voicemail",
"failed_load_audio_edit": "Failed to load audio for editing",
"ringtone_saved": "Ringtone saved successfully",
"failed_save_ringtone": "Failed to save edited ringtone"
},
"tutorial": {
"title": "Getting Started",
@@ -1091,7 +1383,9 @@
"discovery_question": "Do you want to use community interface discovering and auto-connect?",
"discovery_desc": "This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet.",
"yes": "Yes, use discovery",
"no": "No, manual setup"
"no": "No, manual setup",
"discovery_enabled": "Community discovery enabled",
"failed_enable_discovery": "Failed to enable discovery"
},
"command_palette": {
"search_placeholder": "Search commands, navigate, or find peers...",
@@ -1141,6 +1435,8 @@
"action_compose_desc": "Start a new message",
"action_orbit": "Toggle Orbit",
"action_orbit_desc": "Toggle orbit mode",
"action_bouncing_balls": "Bouncing Balls",
"action_bouncing_balls_desc": "Toggle bouncing balls mode",
"action_getting_started": "Getting Started",
"action_getting_started_desc": "Show getting started guide",
"action_changelog": "Changelog",

View File

@@ -190,7 +190,10 @@
"loading_identity": "Загрузка вашей личности",
"emergency_mode_active": "Аварийный режим активен — используется база данных в оперативной памяти и ограниченные службы.",
"blackhole_integration_enabled": "Интеграция Blackhole",
"blackhole_integration_description": "Автоматически блокировать (blackhole) личности на транспортном уровне Reticulum при изгнании пользователей в MeshChatX."
"blackhole_integration_description": "Автоматически блокировать (blackhole) личности на транспортном уровне Reticulum при изгнании пользователей в MeshChatX.",
"failed_announce": "failed to announce",
"search_settings": "Search settings...",
"show_qr": "Show QR Code"
},
"common": {
"open": "Открыть",
@@ -213,7 +216,19 @@
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
"search": "Поиск инструментов...",
"no_results": "Инструменты не найдены",
"error": "Ошибка"
"error": "Ошибка",
"continue": "Continue",
"success": "Success",
"warning": "Warning",
"info": "Info",
"copied": "Copied to clipboard",
"failed_to_copy": "Failed to copy to clipboard",
"save_failed": "Failed to save configuration!",
"invalid_address": "Invalid Address",
"loading": "Loading...",
"ok": "OK",
"copy": "Copy",
"copy_to_clipboard": "Copy to clipboard"
},
"maintenance": {
"title": "Обслуживание и данные",
@@ -254,7 +269,13 @@
"delete_confirm": "Вы уверены, что хотите удалить личность \"{name}\"? Это действие нельзя отменить.",
"switched": "Личность изменена. Приложение будет перезапущено для применения изменений.",
"created": "Личность успешно создана",
"deleted": "Личность удалена"
"deleted": "Личность удалена",
"failed_load": "Failed to load identities",
"enter_display_name_warning": "Please enter a display name",
"failed_create": "Failed to create identity",
"switch_scheduled": "Switch scheduled. Reloading application...",
"failed_switch": "Failed to switch identity",
"failed_delete": "Failed to delete identity"
},
"about": {
"title": "О программе",
@@ -305,7 +326,26 @@
"no_integrity_violations": "Нарушений целостности не обнаружено с момента последнего запуска.",
"dependency_chain": "Цепочка зависимостей",
"other_core_components": "Другие основные компоненты",
"backend_dependencies": "Зависимости бэкенда"
"backend_dependencies": "Зависимости бэкенда",
"integrity_backend_error": "The application backend binary (unpacked from ASAR) appears to have been modified or replaced. This could indicate a malicious actor trying to compromise your mesh communication.",
"integrity_data_error": "Your identities or database files appear to have been modified while the app was closed.",
"integrity_warning_footer": "Proceed with caution. If you did not manually update or modify these files, your installation may be compromised.",
"delete_snapshot_confirm": "Are you sure you want to delete this snapshot?",
"snapshot_deleted": "Snapshot deleted",
"failed_delete_snapshot": "Failed to delete snapshot",
"delete_backup_confirm": "Are you sure you want to delete this backup?",
"backup_deleted": "Backup deleted",
"failed_delete_backup": "Failed to delete backup",
"database_restored": "Database restored. Relaunching...",
"failed_restore_snapshot": "Failed to restore snapshot",
"restore_snapshot_confirm": "Are you sure you want to restore this snapshot? This will overwrite the current database and require an app relaunch.",
"integrity_acknowledged": "Integrity issues acknowledged",
"integrity_acknowledged_reset": "Integrity issues acknowledged and manifest reset",
"integrity_acknowledge_confirm": "Are you sure you want to acknowledge these integrity issues? This will update the security manifest to match the current state of your files.",
"failed_acknowledge_integrity": "Failed to acknowledge integrity issues",
"shutdown_sent": "Shutdown command sent to server.",
"identity_exported": "Identity key file exported",
"identity_copied": "Identity Base32 key copied to clipboard"
},
"interfaces": {
"title": "Интерфейсы",
@@ -321,7 +361,23 @@
"no_interfaces_description": "Измените параметры поиска или добавьте новый интерфейс.",
"restart_required": "Требуется перезапуск",
"restart_description": "Reticulum MeshChatX необходимо перезапустить, чтобы изменения вступили в силу.",
"restart_now": "Перезапустить сейчас"
"restart_now": "Перезапустить сейчас",
"failed_enable": "failed to enable interface",
"failed_disable": "failed to disable interface",
"delete_confirm": "Are you sure you want to delete this interface? This can not be undone!",
"failed_delete": "failed to delete interface",
"failed_export_all": "Failed to export interfaces",
"failed_export_single": "Failed to export interface",
"failed_reload": "Failed to reload Reticulum!",
"interface_not_found": "The selected interface for editing could not be found.",
"discovery_settings_saved": "Discovery settings saved",
"failed_save_discovery": "Failed to save discovery settings",
"no_interfaces_found_config": "No interfaces were found in the selected configuration file",
"failed_parse_config": "Failed to parse configuration file",
"select_config_file": "Please select a configuration file",
"select_at_least_one": "Please select at least one interface to import",
"import_success": "Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.",
"failed_import_all": "Failed to import interfaces"
},
"map": {
"title": "Карта",
@@ -390,7 +446,33 @@
"no_drawings_desc": "Вы еще не сохранили ни одной аннотации на карте.",
"saved_on": "Сохранено",
"settings": "Настройки карты",
"go_to_my_location": "Мое местоположение"
"go_to_my_location": "Мое местоположение",
"source_updated": "Map source updated",
"failed_set_active": "Failed to set active map",
"file_deleted": "File deleted",
"failed_delete_file": "Failed to delete file",
"storage_saved": "Storage directory saved",
"failed_save_storage": "Failed to save directory",
"export_cancelled": "Export cancelled",
"failed_cancel_export": "Failed to cancel export",
"failed_start_export": "Failed to start export",
"select_mbtiles_error": "Please select an .mbtiles file",
"view_saved": "Default view saved",
"failed_save_view": "Failed to save default view",
"failed_clear_cache": "Failed to clear cache",
"failed_save_tile_server": "Failed to save tile server URL",
"failed_save_nominatim": "Failed to save Nominatim API URL",
"copied_coordinates": "Copied coordinates",
"failed_load_drawings": "Failed to load drawings",
"not_initialized": "Map not initialized",
"drawing_saved": "Drawing saved",
"failed_save_drawing": "Failed to save drawing",
"deleted": "Deleted",
"failed_delete": "Failed to delete",
"location_not_determined": "Could not determine your location",
"geolocation_not_supported": "Geolocation is not supported by your browser",
"no_nodes_location": "No discovered nodes with location found",
"failed_fetch_nodes": "Failed to fetch discovered nodes for mapping"
},
"interface": {
"disable": "Выключить",
@@ -454,7 +536,74 @@
"generate_paper_message": "Создать бумажное сообщение (LXM)",
"recording": "Запись: {duration}",
"nomad_network_node": "Узел Nomad Network",
"toggle_source": "Исходный код"
"toggle_source": "Исходный код",
"folder_created": "Folder created",
"failed_create_folder": "Failed to create folder",
"folder_renamed": "Folder renamed",
"failed_rename_folder": "Failed to rename folder",
"folder_deleted": "Folder deleted",
"failed_delete_folder": "Failed to delete folder",
"moved_to_folder": "Moved to folder",
"failed_move_folder": "Failed to move to folder",
"marked_read": "Marked as read",
"failed_mark_read": "Failed to mark as read",
"conversations_deleted": "Conversations deleted",
"failed_delete_conversations": "Failed to delete conversations",
"failed_export_folders": "Failed to export folders",
"folders_imported": "Folders imported",
"failed_import_folders": "Failed to import folders",
"failed_read_clipboard": "Failed to read from clipboard",
"failed_send_ingest": "Failed to send ingest request",
"address_copied": "Your LXMF address copied to clipboard",
"failed_copy_address": "Failed to copy address",
"translation_failed": "Translation failed",
"failed_add_contact": "Failed to add contact",
"ingesting_paper_message": "Ingesting paper message...",
"failed_ingest_paper": "Failed to ingest paper message",
"enter_display_name": "Enter a custom display name",
"failed_update_display_name": "Failed to update display name",
"failed_load_audio": "Failed to load audio attachment.",
"no_contacts_telephone": "No contacts found in telephone",
"failed_load_contacts": "Failed to load contacts",
"location_request_sent": "Location request sent",
"failed_send_location_request": "Failed to send location request",
"remove_image_confirm": "Are you sure you want to remove this image attachment?",
"failed_start_recording": "failed to start recording",
"remove_audio_confirm": "Are you sure you want to remove this audio attachment?",
"failed_generate_qr": "Failed to generate QR",
"enter_folder_name": "Enter folder name",
"new_folder": "New Folder",
"rename_folder": "Rename folder",
"failed_render_qr": "Failed to render QR code",
"uri_copied": "URI copied to clipboard",
"failed_copy_uri": "Failed to copy URI",
"paper_message_sent": "Paper message sent successfully",
"failed_send_paper": "Failed to send paper message",
"failed_load_config": "Failed to load configuration",
"profile_icon_saved": "Profile icon saved successfully",
"failed_save_profile_icon": "Failed to save profile icon",
"select_colors_warning": "Please select both background and icon colors",
"select_icon_warning": "Please select an icon",
"changes_reset": "Changes reset to saved values",
"profile_icon_removed": "Profile icon removed successfully",
"user_banished": "User banished successfully",
"failed_banish_user": "Failed to banish user",
"delete_conversation_confirm": "Are you sure you want to delete this conversation?",
"failed_delete_conversation": "failed to delete conversation",
"delete_history_confirm": "Are you sure you want to delete all messages in this conversation? This can not be undone!",
"failed_delete_history": "failed to delete history",
"invalid_destination_hash": "Invalid destination hash",
"invalid_destination_hash_format": "Invalid destination hash format",
"ping_failed": "Ping failed. Try again later",
"ping_reply_from": "Valid reply from {hash}",
"duration": "Duration: {duration}",
"hops_there": "Hops There: {count}",
"hops_back": "Hops Back: {count}",
"signal_quality": "Signal Quality: {quality}%",
"rssi_val": "RSSI: {rssi}dBm",
"snr_val": "SNR: {snr}dB",
"hash_copied": "Identity hash copied to clipboard",
"failed_to_copy_hash": "Failed to copy identity hash"
},
"nomadnet": {
"remove_favourite": "Удалить из избранного",
@@ -494,7 +643,16 @@
"enter_nomadnet_url": "Введите URL Nomadnet",
"archiving_page": "Архивация страницы...",
"page_archived_successfully": "Страница успешно архивирована.",
"identify_confirm": "Вы уверены, что хотите идентифицировать себя для этого узла NomadNetwork? Страница будет перезагружена после отправки вашей личности."
"identify_confirm": "Вы уверены, что хотите идентифицировать себя для этого узла NomadNetwork? Страница будет перезагружена после отправки вашей личности.",
"failed_rename_favourite": "Failed to rename favourite",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment",
"enter_section_name": "Enter section name",
"new_section": "New Section",
"rename_section": "Rename section",
"delete_section_confirm": "Delete this section? Favourites will move to the main section.",
"unsupported_url": "unsupported url: ",
"no_search_results_peers": "No peers found matching your search."
},
"forwarder": {
"title": "LXMF Форвардер",
@@ -537,7 +695,10 @@
"status_not_downloaded": "Не загружено",
"btn_download": "Скачать руководства",
"btn_update": "Обновить руководство",
"error": "Ошибка"
"error": "Ошибка",
"failed_update_docs": "Failed to update documentation sources",
"docs_link_copied": "Documentation link copied to clipboard",
"failed_copy_link": "Failed to copy link"
},
"tools": {
"utilities": "Утилиты",
@@ -561,7 +722,17 @@
},
"rnpath": {
"title": "RNPath",
"description": "Управление таблицей путей, просмотр частоты анонсов и запрос путей."
"description": "Управление таблицей путей, просмотр частоты анонсов и запрос путей.",
"failed_fetch": "Failed to fetch path data",
"path_dropped": "Path dropped",
"failed_drop": "Could not drop path",
"error_drop": "Error dropping path",
"failed_request": "Failed to request path",
"paths_dropped": "Paths dropped",
"failed_drop_paths": "Failed to drop paths",
"purge_confirm": "Purge all announce queues? This cannot be undone.",
"queues_purged": "Announce queues purged",
"failed_purge": "Failed to purge queues"
},
"translator": {
"title": "Translator",
@@ -890,8 +1061,11 @@
"available_languages": "Доступные языки",
"languages_loaded_from": "Языки загружаются из LibreTranslate API или пакетов Argos Translate.",
"refresh_languages": "Обновить языки",
"failed_to_load_languages": "Не удалось загрузить языки. Убедитесь, что LibreTranslate запущен или Argos Translate установлен.",
"copied_to_clipboard": "Скопировано в буфер обмена"
"copied_to_clipboard": "Скопировано в буфер обмена",
"failed_load_languages": "Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed.",
"failed_translate": "Translation failed. Make sure LibreTranslate is running or Argos Translate is installed.",
"select_languages_warning": "Please select both source and target languages.",
"auto_detect_not_supported": "Auto-detection is not supported with Argos Translate. Please select a source language."
},
"call": {
"phone": "Телефон",
@@ -1012,7 +1186,36 @@
"failed_to_set_primary_ringtone": "Не удалось установить основной рингтон",
"failed_to_update_ringtone_name": "Не удалось обновить название рингтона",
"failed_to_play_ringtone": "Не удалось воспроизвести рингтон",
"failed_to_play_voicemail": "Не удалось воспроизвести сообщение"
"failed_to_play_voicemail": "Не удалось воспроизвести сообщение",
"web_audio_not_available": "Web audio not available",
"failed_to_update_dnd": "Failed to update Do Not Disturb status",
"failed_to_update_call_settings": "Failed to update call settings",
"failed_to_update_recording_status": "Failed to update call recording status",
"call_history_cleared": "Call history cleared",
"failed_to_clear_call_history": "Failed to clear call history",
"identity_banished": "Identity banished",
"failed_to_banish_identity": "Failed to banish identity",
"name_and_hash_required": "Name and identity hash required",
"contact_updated": "Contact updated",
"contact_added": "Contact added",
"contact_deleted": "Contact deleted",
"failed_to_delete_contact": "Failed to delete contact",
"hash_copied": "Hash copied to clipboard",
"failed_to_copy_hash": "Failed to copy hash",
"greeting_uploaded_successfully": "Greeting uploaded successfully",
"greeting_deleted": "Greeting deleted",
"failed_to_delete_greeting": "Failed to delete greeting",
"failed_to_start_recording_greeting": "Failed to start recording greeting",
"greeting_recorded_from_mic": "Greeting recorded from mic",
"failed_to_stop_recording_greeting": "Failed to stop recording greeting",
"failed_to_load_recording": "Failed to load recording audio",
"recording_deleted": "Recording deleted",
"failed_to_delete_recording": "Failed to delete recording",
"call_sent_to_voicemail": "Call sent to voicemail",
"failed_to_send_to_voicemail": "Failed to send call to voicemail",
"failed_load_audio_edit": "Failed to load audio for editing",
"ringtone_saved": "Ringtone saved successfully",
"failed_save_ringtone": "Failed to save edited ringtone"
},
"tutorial": {
"title": "Начало работы",
@@ -1091,7 +1294,9 @@
"discovery_question": "Вы хотите использовать обнаружение интерфейсов сообщества и автоподключение?",
"discovery_desc": "Это позволяет MeshChatX автоматически находить и подключаться к публичным узлам сообщества рядом с вами или в интернете.",
"yes": "Да, использовать обнаружение",
"no": "Нет, ручная настройка"
"no": "Нет, ручная настройка",
"discovery_enabled": "Community discovery enabled",
"failed_enable_discovery": "Failed to enable discovery"
},
"command_palette": {
"search_placeholder": "Поиск команд, навигация или поиск узлов...",
@@ -1141,9 +1346,100 @@
"action_compose_desc": "Начать новое сообщение",
"action_orbit": "Переключить Orbit",
"action_orbit_desc": "Переключить режим Orbit",
"action_bouncing_balls": "Прыгающие шарики",
"action_bouncing_balls_desc": "Переключить режим прыгающих шариков",
"action_getting_started": "Начало работы",
"action_getting_started_desc": "Показать руководство",
"action_changelog": "Список изменений",
"action_changelog_desc": "Просмотр изменений"
},
"settings": {
"shortcut_saved": "Shortcut saved",
"shortcut_deleted": "Shortcut deleted",
"archived_pages_flushed": "Archived pages flushed.",
"failed_enable_transport": "Failed to enable transport mode!",
"failed_disable_transport": "Failed to disable transport mode!",
"failed_reload_reticulum": "Failed to reload Reticulum!",
"folders_exported": "Folders exported",
"failed_export_folders": "Failed to export folders",
"folders_imported": "Folders and mappings imported successfully",
"failed_import_folders": "Failed to import folders"
},
"debug": {
"title": "Debug Logs",
"description": "View application logs and detected anomalies.",
"failed_fetch_logs": "Failed to fetch logs",
"logs_copied": "Logs on this page copied to clipboard",
"failed_copy_logs": "Failed to copy logs"
},
"visualiser": {
"title": "Network Visualiser",
"description": "Visualize the mesh network topology.",
"reticulum_mesh": "Reticulum Mesh",
"network_visualizer": "Network Visualizer",
"node_info": "Node Info",
"hash": "Hash",
"hops": "Hops",
"interface": "Interface",
"rssi": "RSSI",
"snr": "SNR",
"first_seen": "First seen",
"last_seen": "Last seen",
"center_node": "Center on node",
"copy_hash": "Copy Hash",
"banish_identity": "Banish identity",
"banished_identities": "Banished identities",
"filter_nodes": "Filter nodes...",
"all": "All",
"direct": "Direct",
"one_hop": "1 Hop",
"multi_hops": "2+ Hops",
"clear": "Clear",
"show_labels": "Show all labels",
"bouncing_balls": "Bouncing balls mode",
"orbit_mode": "Orbit mode",
"show_edges": "Show edges",
"show_tooltips": "Show tooltips",
"show_rssi": "Show RSSI",
"show_snr": "Show SNR",
"show_hops": "Show Hops",
"physics": "Physics",
"gravity": "Gravity",
"spring_length": "Spring length",
"spring_strength": "Spring strength",
"damping": "Damping",
"central_gravity": "Central gravity",
"edge_strength": "Edge strength",
"force_atlas_2": "Force Atlas 2",
"barns_hut": "Barns Hut",
"repulsion": "Repulsion",
"reset_physics": "Reset physics",
"reset_camera": "Reset Camera",
"clear_graph": "Clear graph",
"total_nodes": "Total nodes",
"total_edges": "Total edges",
"discovered": "Discovered",
"wait_between_batches": "Wait between batches",
"batch": "Batch",
"loading_status": "Loading status",
"failed_load": "Failed to load network data"
},
"banishment": {
"title": "Banished",
"description": "Manage Banished users and nodes",
"failed_load_banished": "Failed to load banished destinations",
"search_placeholder": "Search by hash or display name...",
"loading_items": "Loading banished items...",
"no_items": "No banished items found",
"lift_banishment": "Lift Banishment",
"lift_banishment_confirm": "Are you sure you want to lift the banishment for {name}?",
"banished_at": "Banished at",
"banished_until": "Banished until",
"reason": "Reason",
"type": "Type",
"user": "User",
"node": "Node",
"banishment_lifted": "Banishment lifted successfully",
"failed_lift_banishment": "Failed to lift banishment"
}
}

View File

@@ -61,7 +61,17 @@ describe("BlockedPage.vue (Banished UI)", () => {
return mount(BlockedPage, {
global: {
mocks: {
$t: (key) => key,
$t: (key) => {
const translations = {
"banishment.title": "Banished",
"banishment.description": "Manage Banished users and nodes",
"banishment.lift_banishment": "Lift Banishment",
"banishment.user": "User",
"banishment.node": "Node",
"banishment.banished_at": "Banished at",
};
return translations[key] || key;
},
},
stubs: {
MaterialDesignIcon: {

View File

@@ -2,6 +2,15 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MicronEditorPage from "@/components/micron-editor/MicronEditorPage.vue";
import { micronStorage } from "@/js/MicronStorage";
import DialogUtils from "@/js/DialogUtils";
// Mock DialogUtils
vi.mock("@/js/DialogUtils", () => ({
default: {
confirm: vi.fn().mockResolvedValue(true),
alert: vi.fn().mockResolvedValue(),
},
}));
// Mock micronStorage
vi.mock("@/js/MicronStorage", () => ({
@@ -127,8 +136,10 @@ describe("MicronEditorPage.vue", () => {
// Find reset button
const resetButton = wrapper.find('.mdi-stub[data-icon-name="refresh"]').element.parentElement;
await resetButton.click();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick(); // Wait for async resetAll to complete
expect(window.confirm).toHaveBeenCalled();
expect(DialogUtils.confirm).toHaveBeenCalled();
expect(micronStorage.clearAll).toHaveBeenCalled();
expect(wrapper.vm.tabs.length).toBe(2); // Resets to Main + Guide
expect(wrapper.vm.activeTabIndex).toBe(0);

View File

@@ -125,6 +125,16 @@ describe("NetworkVisualiser.vue", () => {
const mountVisualiser = () => {
return mount(NetworkVisualiser, {
global: {
mocks: {
$t: (msg) => {
const translations = {
"visualiser.reticulum_mesh": "Reticulum Mesh",
"visualiser.total_nodes": "Nodes",
"visualiser.total_edges": "Links",
};
return translations[msg] || msg;
},
},
stubs: {
Toggle: {
template:

View File

@@ -10,6 +10,9 @@ describe("Toast.vue", () => {
vi.useFakeTimers();
wrapper = mount(Toast, {
global: {
mocks: {
$t: (msg) => msg,
},
stubs: {
TransitionGroup: { template: "<div><slot /></div>" },
MaterialDesignIcon: {