feat(frontend): interfaces, archives, messages, settings, i18n

This commit is contained in:
Ivan
2026-03-24 00:38:54 +03:00
parent 149d58f058
commit 83ed463c3d
8 changed files with 1312 additions and 823 deletions

View File

@@ -55,6 +55,14 @@
</label>
</div>
<button
v-if="selectedArchives.length > 0"
class="text-[10px] font-bold uppercase tracking-wider text-blue-500 hover:text-blue-600 transition-colors flex items-center gap-1 mr-2"
@click="exportSelectedArchivesAsMu"
>
<MaterialDesignIcon icon-name="download" class="size-3.5" />
{{ $t("archives.export_selected_mu", { count: selectedArchives.length }) }}
</button>
<button
v-if="selectedArchives.length > 0"
class="text-[10px] font-bold uppercase tracking-wider text-red-500 hover:text-red-600 transition-colors flex items-center gap-1"
@@ -157,6 +165,17 @@
<MaterialDesignIcon icon-name="view-list" class="size-4" />
</button>
<div class="hidden sm:block w-px h-6 bg-zinc-800 mx-1"></div>
<button
class="p-2 hover:bg-zinc-800 rounded transition-colors text-zinc-300 flex items-center gap-2"
:title="$t('archives.export_mu')"
@click="exportArchiveAsMu(viewingArchive)"
>
<MaterialDesignIcon icon-name="download" class="size-4" />
<span class="hidden xs:inline text-xs font-bold uppercase tracking-wider">{{
$t("archives.export_mu")
}}</span>
</button>
<div class="hidden xs:block w-px h-6 bg-zinc-800 mx-1"></div>
<button
class="p-2 hover:bg-zinc-800 rounded transition-colors text-blue-400 flex items-center gap-2"
@click="openInNomadnet(viewingArchive)"
@@ -239,8 +258,9 @@ export default {
groupedArchives() {
// Optimization: Use a simple object for grouping
const groups = {};
for (let i = 0; i < this.archives.length; i++) {
const archive = this.archives[i];
const list = this.archives || [];
for (let i = 0; i < list.length; i++) {
const archive = list[i];
const hash = archive.destination_hash;
if (!groups[hash]) {
groups[hash] = {
@@ -398,6 +418,50 @@ export default {
formatDate(dateStr) {
return Utils.formatTimeAgo(dateStr);
},
downloadTextAsFile(content, filename) {
const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
muExportBasename(archive) {
let base = (archive.page_path || "page").split("/").pop() || "page";
base = base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "page";
return base;
},
muExportFilename(archive) {
let base = this.muExportBasename(archive);
if (base.toLowerCase().endsWith(".mu")) {
return base;
}
const without = base.includes(".") ? base.replace(/\.[^.]+$/, "") : base;
return `${without || "page"}.mu`;
},
muExportFilenameDisambiguated(archive) {
const stem = this.muExportFilename(archive).replace(/\.mu$/i, "");
const short = (archive.hash || "snap").substring(0, 8);
return `${stem}_${short}.mu`;
},
exportArchiveAsMu(archive) {
if (!archive) {
return;
}
this.downloadTextAsFile(archive.content, this.muExportFilename(archive));
},
exportSelectedArchivesAsMu() {
const list = this.archives.filter((a) => this.selectedArchives.includes(a.id));
list.forEach((archive, i) => {
window.setTimeout(() => {
this.downloadTextAsFile(archive.content, this.muExportFilenameDisambiguated(archive));
}, i * 120);
});
},
renderFullContent(archive) {
if (!archive.content) return "";

View File

@@ -1105,6 +1105,7 @@
:placeholder="$t('messages.send_placeholder')"
@keydown.enter.exact.prevent="onEnterPressed"
@keydown.enter.shift.exact.prevent="onShiftEnterPressed"
@paste="onMessagePaste"
></textarea>
<!-- reply preview -->
@@ -1265,6 +1266,15 @@
<MaterialDesignIcon icon-name="refresh" class="size-4" />
<span class="font-medium">Retry</span>
</button>
<button
v-if="isSelectedPeerBlocked && selectedPeer"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
@click="liftBanishmentFromMessageMenu"
>
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
<span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<button
type="button"
@@ -2925,6 +2935,23 @@ export default {
console.error(e);
}
},
async liftBanishmentFromMessageMenu() {
if (!this.selectedPeer?.destination_hash) {
this.messageContextMenu.show = false;
return;
}
try {
await window.axios.delete(
`/api/v1/blocked-destinations/${this.selectedPeer.destination_hash}`
);
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert(this.$t("banishment.banishment_lifted"));
} catch (e) {
DialogUtils.alert(this.$t("banishment.failed_lift_banishment"));
console.error(e);
}
this.messageContextMenu.show = false;
},
onChatItemClick: function (chatItem) {
if (!chatItem.is_actions_expanded) {
chatItem.is_actions_expanded = true;
@@ -2973,8 +3000,8 @@ export default {
this.messageContextMenu.show = true;
this.$nextTick(() => {
const menuWidth = 180;
const menuHeight = 150;
const menuWidth = 200;
const menuHeight = 280;
let x = event.clientX;
let y = event.clientY;
@@ -3673,6 +3700,35 @@ export default {
DialogUtils.alert(message);
}
},
onMessagePaste(event) {
const cd = event.clipboardData;
if (!cd?.items?.length) {
return;
}
const imageBlobs = [];
for (let i = 0; i < cd.items.length; i++) {
const item = cd.items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
const f = item.getAsFile();
if (f) {
imageBlobs.push(f);
}
}
}
if (imageBlobs.length === 0) {
return;
}
event.preventDefault();
const t = Date.now();
imageBlobs.forEach((file, idx) => {
let f = file;
const ext = (file.type.split("/")[1] || "png").replace("jpeg", "jpg");
if (!file.name || file.name === "image.png" || file.name === "image.jpeg") {
f = new File([file], `paste-${t}-${idx}.${ext}`, { type: file.type });
}
this.onImageSelected(f);
});
},
async pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();

View File

@@ -1078,31 +1078,25 @@
"
/>
</div>
<div class="space-y-3">
<div class="space-y-4">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.announce_limits") }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.announce_limits_description") }}
</div>
<div class="text-xs font-semibold text-gray-700 dark:text-zinc-300 uppercase tracking-wide">
{{ $t("app.announce_max_stored_heading") }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_lxmf") }}</label>
<input
v-model.number="config.announce_limit_lxmf_delivery"
v-model.number="config.announce_max_stored_lxmf_delivery"
type="number"
min="0"
min="1"
class="input-field"
placeholder="-"
@change="
updateConfig(
{
announce_limit_lxmf_delivery:
config.announce_limit_lxmf_delivery ?? null,
},
'announce_limits'
)
"
@change="onAnnounceLimitsChange"
/>
</div>
<div class="space-y-1">
@@ -1110,42 +1104,91 @@
$t("app.announce_limit_nomadnet")
}}</label>
<input
v-model.number="config.announce_limit_nomadnetwork_node"
v-model.number="config.announce_max_stored_nomadnetwork_node"
type="number"
min="0"
min="1"
class="input-field"
placeholder="-"
@change="
updateConfig(
{
announce_limit_nomadnetwork_node:
config.announce_limit_nomadnetwork_node ?? null,
},
'announce_limits'
)
"
@change="onAnnounceLimitsChange"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_prop") }}</label>
<input
v-model.number="config.announce_limit_lxmf_propagation"
v-model.number="config.announce_max_stored_lxmf_propagation"
type="number"
min="0"
min="1"
class="input-field"
placeholder="-"
@change="
updateConfig(
{
announce_limit_lxmf_propagation:
config.announce_limit_lxmf_propagation ?? null,
},
'announce_limits'
)
"
@change="onAnnounceLimitsChange"
/>
</div>
</div>
<div class="text-xs font-semibold text-gray-700 dark:text-zinc-300 uppercase tracking-wide">
{{ $t("app.announce_fetch_limit_heading") }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_lxmf") }}</label>
<input
v-model.number="config.announce_fetch_limit_lxmf_delivery"
type="number"
min="1"
class="input-field"
@change="onAnnounceLimitsChange"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium">{{
$t("app.announce_limit_nomadnet")
}}</label>
<input
v-model.number="config.announce_fetch_limit_nomadnetwork_node"
type="number"
min="1"
class="input-field"
@change="onAnnounceLimitsChange"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_prop") }}</label>
<input
v-model.number="config.announce_fetch_limit_lxmf_propagation"
type="number"
min="1"
class="input-field"
@change="onAnnounceLimitsChange"
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_search_max_fetch") }}</label>
<input
v-model.number="config.announce_search_max_fetch"
type="number"
min="100"
class="input-field"
@change="onAnnounceLimitsChange"
/>
<p class="text-[10px] text-gray-500 dark:text-zinc-500">
{{ $t("app.announce_search_max_fetch_hint") }}
</p>
</div>
<div class="space-y-1">
<label class="text-xs font-medium">{{
$t("app.discovered_interfaces_max_return")
}}</label>
<input
v-model.number="config.discovered_interfaces_max_return"
type="number"
min="1"
class="input-field"
@change="onAnnounceLimitsChange"
/>
<p class="text-[10px] text-gray-500 dark:text-zinc-500">
{{ $t("app.discovered_interfaces_max_return_hint") }}
</p>
</div>
</div>
</div>
</div>
</section>
@@ -1496,7 +1539,9 @@
@update:model-value="onInboundStampsEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.inbound_stamps_required_title") }}</span>
<span class="setting-toggle__title">{{
$t("app.inbound_stamps_required_title")
}}</span>
<span class="setting-toggle__description">{{
$t("app.inbound_stamps_required_description")
}}</span>
@@ -1762,9 +1807,14 @@ export default {
banished_text: "BANISHED",
banished_color: "#dc2626",
blackhole_integration_enabled: true,
announce_limit_lxmf_delivery: null,
announce_limit_nomadnetwork_node: null,
announce_limit_lxmf_propagation: null,
announce_max_stored_lxmf_delivery: 1000,
announce_max_stored_nomadnetwork_node: 1000,
announce_max_stored_lxmf_propagation: 1000,
announce_fetch_limit_lxmf_delivery: 500,
announce_fetch_limit_nomadnetwork_node: 500,
announce_fetch_limit_lxmf_propagation: 500,
announce_search_max_fetch: 2000,
discovered_interfaces_max_return: 500,
message_font_size: 14,
message_icon_size: 28,
message_outbound_bubble_color: "#4f46e5",
@@ -1867,6 +1917,10 @@ export default {
"app.announce_limit_lxmf",
"app.announce_limit_nomadnet",
"app.announce_limit_prop",
"app.announce_max_stored_heading",
"app.announce_fetch_limit_heading",
"app.announce_search_max_fetch",
"app.discovered_interfaces_max_return",
],
transport: [
"Reticulum",
@@ -2083,6 +2137,37 @@ export default {
console.log(e);
}
},
numOrNull(v) {
if (v === null || v === undefined || v === "") {
return null;
}
const n = Number(v);
return Number.isFinite(n) ? n : null;
},
async onAnnounceLimitsChange() {
const c = this.config;
await this.updateConfig(
{
announce_max_stored_lxmf_delivery: this.numOrNull(c.announce_max_stored_lxmf_delivery),
announce_max_stored_nomadnetwork_node: this.numOrNull(
c.announce_max_stored_nomadnetwork_node
),
announce_max_stored_lxmf_propagation: this.numOrNull(
c.announce_max_stored_lxmf_propagation
),
announce_fetch_limit_lxmf_delivery: this.numOrNull(c.announce_fetch_limit_lxmf_delivery),
announce_fetch_limit_nomadnetwork_node: this.numOrNull(
c.announce_fetch_limit_nomadnetwork_node
),
announce_fetch_limit_lxmf_propagation: this.numOrNull(
c.announce_fetch_limit_lxmf_propagation
),
announce_search_max_fetch: this.numOrNull(c.announce_search_max_fetch),
discovered_interfaces_max_return: this.numOrNull(c.discovered_interfaces_max_return),
},
"announce_limits"
);
},
async copyValue(value, label) {
if (!value) {
ToastUtils.warning(`Nothing to copy for ${label}`);
@@ -2265,10 +2350,7 @@ export default {
);
return;
}
const restore = Math.min(
254,
Math.max(1, Number(this.lastRememberedInboundStampCost) || 8)
);
const restore = Math.min(254, Math.max(1, Number(this.lastRememberedInboundStampCost) || 8));
this.config.lxmf_inbound_stamp_cost = restore;
await this.updateConfig(
{

View File

@@ -207,7 +207,13 @@
"blackhole_integration_enabled": "Blackhole-Integration",
"blackhole_integration_description": "Identitäten automatisch auf der Reticulum-Transportebene sperren (Blackhole), wenn Benutzer in MeshChatX verbannt werden.",
"announce_limits": "Ankündigungslimits",
"announce_limits_description": "Optionale maximale Anzahl pro Aspekt. Leer lassen für kein Limit. Schützt vor Flood-Angriffen in öffentlichen Netzen.",
"announce_limits_description": "Begrenzt gespeicherte Ankündigungen pro Aspekt (älteste werden entfernt) und wie viele pro API-Anfrage geladen werden. Begrenzt auch Suche und erkannte Schnittstellen. Feld leeren und speichern setzt Standardwerte.",
"announce_max_stored_heading": "Max. gespeichert pro Aspekt (Datenbank)",
"announce_fetch_limit_heading": "Standard-Seitengröße (API)",
"announce_search_max_fetch": "Max. Ankündigungen für Suche",
"announce_search_max_fetch_hint": "Maximale Zeilen aus der Datenbank bei Textsuche (pro Anfrage).",
"discovered_interfaces_max_return": "Limit erkannte Schnittstellen",
"discovered_interfaces_max_return_hint": "Maximale Einträge für erkannte und aktive Schnittstellen in der Oberfläche.",
"announce_limit_lxmf": "LXMF",
"announce_limit_nomadnet": "NomadNet",
"announce_limit_prop": "Prop-Knoten",
@@ -505,7 +511,20 @@
"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"
"failed_import_all": "Failed to import interfaces",
"add_interface_sidebar_a11y": "Verknüpfungen und Community-Voreinstellungen",
"community_quick_start": "Community-Schnellstart",
"community_quick_start_hint": "Voreinstellungen von directory.rns.recipes (Online-Listings).",
"community_quick_start_hide": "Vorschläge ausblenden",
"community_use_preset": "Übernehmen",
"community_presets_hidden_hint": "Community-Voreinstellungen sind ausgeblendet. Hier oder in den Einstellungen wieder anzeigen.",
"community_presets_show_again": "Community-Voreinstellungen anzeigen",
"community_presets_empty": "Keine Voreinstellungen. Bundle neu bauen oder public/community_interfaces.json bereitstellen.",
"find_more_nodes": "Weitere Knoten finden",
"quick_import": "Schnellimport",
"quick_import_paste_hint": "Rohkonfiguration einfügen",
"quick_import_placeholder": "[[Interface Name]]\ntype = TCPClientInterface\ntarget_host = 1.2.3.4",
"quick_import_apply": "Anwenden: {name}"
},
"map": {
"title": "Karte",
@@ -827,7 +846,9 @@
"pages": "Seiten",
"view": "Anzeigen",
"showing_range": "Zeige {start} bis {end} von {total} Archiven",
"page_of": "Seite {page} von {total_pages}"
"page_of": "Seite {page} von {total_pages}",
"export_mu": "Als .mu exportieren",
"export_selected_mu": ".mu exportieren ({count})"
},
"docs": {
"title": "Dokumentation",

View File

@@ -210,7 +210,13 @@
"blackhole_integration_enabled": "Blackhole Integration",
"blackhole_integration_description": "Automatically blackhole identities at the Reticulum transport layer when banishing users in MeshChatX.",
"announce_limits": "Announce Limits",
"announce_limits_description": "Optional max count per aspect. Leave empty for no limit. Protects against flood attacks on public networks.",
"announce_limits_description": "Limit how many announces are kept in the database per aspect (oldest removed first) and how many load per API request. Also caps search and discovered-interface payloads. Clear a numeric field and save to restore defaults.",
"announce_max_stored_heading": "Max stored per aspect (database)",
"announce_fetch_limit_heading": "Default page size (API)",
"announce_search_max_fetch": "Max announces for search",
"announce_search_max_fetch_hint": "Maximum rows read from the database when you use text search on announces (per request).",
"discovered_interfaces_max_return": "Discovered interfaces cap",
"discovered_interfaces_max_return_hint": "Maximum discovered and active interface entries returned to the UI.",
"announce_limit_lxmf": "LXMF",
"announce_limit_nomadnet": "NomadNet",
"announce_limit_prop": "Prop Nodes",
@@ -459,7 +465,20 @@
"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"
"failed_import_all": "Failed to import interfaces",
"add_interface_sidebar_a11y": "Shortcuts and community presets",
"community_quick_start": "Community Quick-Start",
"community_quick_start_hint": "Presets from directory.rns.recipes online listings.",
"community_quick_start_hide": "Hide suggested interfaces",
"community_use_preset": "Use This",
"community_presets_hidden_hint": "Community presets are hidden. Show them again here or in Settings.",
"community_presets_show_again": "Show community presets",
"community_presets_empty": "No presets loaded. Rebuild the bundle or add public/community_interfaces.json.",
"find_more_nodes": "Find more nodes",
"quick_import": "Quick Import",
"quick_import_paste_hint": "Paste raw config",
"quick_import_placeholder": "[[Interface Name]]\ntype = TCPClientInterface\ntarget_host = 1.2.3.4",
"quick_import_apply": "Apply: {name}"
},
"map": {
"title": "Map",
@@ -870,7 +889,9 @@
"pages": "Pages",
"view": "View",
"showing_range": "Showing {start} to {end} of {total} archives",
"page_of": "Page {page} of {total_pages}"
"page_of": "Page {page} of {total_pages}",
"export_mu": "Export .mu",
"export_selected_mu": "Export .mu ({count})"
},
"docs": {
"title": "Documentation",

View File

@@ -210,7 +210,13 @@
"blackhole_integration_enabled": "Integrazione Blackhole",
"blackhole_integration_description": "Metti automaticamente in blackhole le identità a livello di trasporto Reticulum quando esili gli utenti in MeshChatX.",
"announce_limits": "Limiti annunci",
"announce_limits_description": "Numero massimo opzionale per aspetto. Lasciare vuoto per nessun limite. Protegge da attacchi flood su reti pubbliche.",
"announce_limits_description": "Limita quanti annunci restano nel database per aspetto (si eliminano i più vecchi) e quanti vengono caricati per richiesta API. Limita anche ricerca e interfacce scoperte. Svuota un campo e salva per i default.",
"announce_max_stored_heading": "Massimo salvato per aspetto (database)",
"announce_fetch_limit_heading": "Dimensione pagina predefinita (API)",
"announce_search_max_fetch": "Massimo annunci per ricerca",
"announce_search_max_fetch_hint": "Righe massime lette dal database con ricerca testuale (per richiesta).",
"discovered_interfaces_max_return": "Limite interfacce scoperte",
"discovered_interfaces_max_return_hint": "Massimo elementi per interfacce scoperte e attive nell'interfaccia.",
"announce_limit_lxmf": "LXMF",
"announce_limit_nomadnet": "NomadNet",
"announce_limit_prop": "Nodi prop",
@@ -505,7 +511,20 @@
"select_config_file": "Seleziona un file di configurazione",
"select_at_least_one": "Seleziona almeno un'interfaccia da importare",
"import_success": "Interfacce importate con successo. MeshChat deve essere riavviato per applicare le modifiche.",
"failed_import_all": "Impossibile importare le interfacce"
"failed_import_all": "Impossibile importare le interfacce",
"add_interface_sidebar_a11y": "Collegamenti e preset community",
"community_quick_start": "Avvio rapido community",
"community_quick_start_hint": "Preset da directory.rns.recipes (elenchi online).",
"community_quick_start_hide": "Nascondi suggerimenti",
"community_use_preset": "Usa",
"community_presets_hidden_hint": "I preset community sono nascosti. Mostra di nuovo qui o in Impostazioni.",
"community_presets_show_again": "Mostra preset community",
"community_presets_empty": "Nessun preset. Ricostruisci il bundle o aggiungi public/community_interfaces.json.",
"find_more_nodes": "Trova altri nodi",
"quick_import": "Import rapido",
"quick_import_paste_hint": "Incolla config grezza",
"quick_import_placeholder": "[[Interface Name]]\ntype = TCPClientInterface\ntarget_host = 1.2.3.4",
"quick_import_apply": "Applica: {name}"
},
"map": {
"title": "Mappa",
@@ -916,7 +935,9 @@
"pages": "Pagine",
"view": "Visualizza",
"showing_range": "Visualizzazione da {start} a {end} di {total} archivi",
"page_of": "Pagina {page} di {total_pages}"
"page_of": "Pagina {page} di {total_pages}",
"export_mu": "Esporta .mu",
"export_selected_mu": "Esporta .mu ({count})"
},
"docs": {
"title": "Documentazione",

View File

@@ -207,7 +207,13 @@
"blackhole_integration_enabled": "Интеграция Blackhole",
"blackhole_integration_description": "Автоматически блокировать (blackhole) личности на транспортном уровне Reticulum при изгнании пользователей в MeshChatX.",
"announce_limits": "Лимиты объявлений",
"announce_limits_description": "Необязательный максимум по аспекту. Оставьте пустым для отсутствия лимита. Защита от флуд-атак в публичных сетях.",
"announce_limits_description": "Ограничение числа объявлений в базе по аспекту (удаляются самые старые) и сколько загружается за запрос API. Также лимит поиска и списка обнаруженных интерфейсов. Очистите поле и сохраните для значений по умолчанию.",
"announce_max_stored_heading": "Макс. в базе на аспект",
"announce_fetch_limit_heading": "Размер страницы по умолчанию (API)",
"announce_search_max_fetch": "Макс. объявлений для поиска",
"announce_search_max_fetch_hint": "Максимум строк из БД при текстовом поиске (на запрос).",
"discovered_interfaces_max_return": "Лимит обнаруженных интерфейсов",
"discovered_interfaces_max_return_hint": "Максимум записей об обнаруженных и активных интерфейсах в интерфейсе.",
"announce_limit_lxmf": "LXMF",
"announce_limit_nomadnet": "NomadNet",
"announce_limit_prop": "Prop-узлы",
@@ -505,7 +511,20 @@
"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"
"failed_import_all": "Failed to import interfaces",
"add_interface_sidebar_a11y": "Ярлыки и пресеты сообщества",
"community_quick_start": "Быстрый старт (сообщество)",
"community_quick_start_hint": "Пресеты из онлайн-списков directory.rns.recipes.",
"community_quick_start_hide": "Скрыть подсказки",
"community_use_preset": "Применить",
"community_presets_hidden_hint": "Пресеты сообщества скрыты. Показать снова здесь или в настройках.",
"community_presets_show_again": "Показать пресеты сообщества",
"community_presets_empty": "Пресеты не загружены. Пересоберите bundle или добавьте public/community_interfaces.json.",
"find_more_nodes": "Найти узлы",
"quick_import": "Быстрый импорт",
"quick_import_paste_hint": "Вставьте сырую конфигурацию",
"quick_import_placeholder": "[[Interface Name]]\ntype = TCPClientInterface\ntarget_host = 1.2.3.4",
"quick_import_apply": "Применить: {name}"
},
"map": {
"title": "Карта",
@@ -827,7 +846,9 @@
"pages": "Страницы",
"view": "Просмотр",
"showing_range": "Показано с {start} по {end} из {total} архивов",
"page_of": "Страница {page} из {total_pages}"
"page_of": "Страница {page} из {total_pages}",
"export_mu": "Экспорт .mu",
"export_selected_mu": "Экспорт .mu ({count})"
},
"docs": {
"title": "Документация",