feat(map): add popout functionality for the map component and improve UI with responsive design updates

This commit is contained in:
Ivan
2026-04-12 18:08:11 -05:00
parent 92080c17b9
commit f3da41a5ef
9 changed files with 237 additions and 124 deletions
@@ -2,11 +2,11 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full">
<div class="p-3 md:p-6 space-y-4 w-full flex-1">
<div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-8 py-4 sm:py-6">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto flex-1">
<div
v-if="showRestartReminder"
class="bg-amber-600 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
class="bg-amber-600 text-white border border-amber-500/30 p-4 sm:rounded-xl flex flex-wrap gap-3 items-center mb-4 sm:mb-6"
>
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
@@ -26,7 +26,9 @@
</button>
</div>
<div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
<div
class="interfaces-section interfaces-section--hero flex flex-col md:flex-row md:items-center justify-between gap-6"
>
<div class="space-y-3 flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("interfaces.manage") }}
@@ -38,7 +40,10 @@
{{ $t("interfaces.description") }}
</div>
<div class="flex flex-wrap gap-2 pt-2">
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
<RouterLink
:to="{ name: 'interfaces.add' }"
class="primary-chip px-4 py-2 text-sm min-h-[44px] sm:min-h-0 items-center justify-center inline-flex"
>
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
{{ $t("interfaces.add_interface") }}
</RouterLink>
@@ -87,7 +92,7 @@
</div>
</div>
<div class="glass-card space-y-4">
<div class="interfaces-section space-y-4">
<div class="flex flex-wrap gap-2">
<button
v-for="tab in ['overview', 'discovery']"
@@ -102,7 +107,7 @@
</div>
<div v-if="activeTab === 'overview'" class="space-y-4">
<div class="glass-card space-y-3">
<div class="interfaces-subpanel space-y-3">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -160,14 +165,17 @@
@delete="deleteInterface(iface._name)"
/>
</div>
<div v-else class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
<div
v-else
class="text-center py-10 px-4 text-gray-500 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-xl"
>
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
<div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
</div>
</div>
<div class="glass-card space-y-3">
<div class="interfaces-subpanel space-y-3">
<div class="flex items-center gap-3">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -436,7 +444,7 @@
</div>
<div v-else class="space-y-4">
<div class="glass-card space-y-4">
<div class="interfaces-subpanel space-y-4">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -1266,3 +1274,15 @@ export default {
},
};
</script>
<style scoped>
.interfaces-section {
@apply w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
}
.interfaces-section--hero {
@apply border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
}
.interfaces-subpanel {
@apply mt-6 pt-6 border-t border-gray-200/50 dark:border-zinc-800/50 first:mt-0 first:pt-0 first:border-0;
}
</style>
@@ -10,6 +10,16 @@
</div>
<div class="ml-auto flex items-center space-x-2">
<button
v-if="!isPopoutMode"
type="button"
class="hidden sm:flex items-center gap-1.5 px-2.5 py-1.5 text-sm font-medium text-gray-600 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
:title="$t('map.pop_out')"
@click="openMapPopout"
>
<MaterialDesignIcon icon-name="open-in-new" class="size-4" />
<span>{{ $t("map.pop_out") }}</span>
</button>
<!-- offline/online toggle -->
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
<button
@@ -1441,6 +1451,15 @@ export default {
};
},
computed: {
popoutRouteType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.popoutRouteType === "map";
},
trackedPeers() {
return this.telemetryList.filter((t) => t.is_tracking);
},
@@ -3692,6 +3711,15 @@ export default {
ToastUtils.error(this.$t("map.failed_fetch_nodes"));
}
},
openMapPopout() {
const url = `${window.location.origin}${window.location.pathname}#/popout/map`;
window.open(url, "_blank", "width=960,height=720,noopener");
},
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
},
};
</script>
@@ -142,6 +142,14 @@
</div>
</div>
<IconButton
class="md:hidden shrink-0 text-gray-700 dark:text-gray-300"
:title="$t('nomadnet.identify')"
@click="identify(selectedNode.destination_hash)"
>
<MaterialDesignIcon icon-name="fingerprint" class="size-5" />
</IconButton>
<div class="hidden md:flex items-center gap-1 shrink-0">
<IconButton
class="text-gray-700 dark:text-gray-300"
@@ -173,10 +181,6 @@
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click="identify(selectedNode.destination_hash)">
<MaterialDesignIcon icon-name="fingerprint" class="size-5" />
<span>{{ $t("nomadnet.identify") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="openNomadnetPopout">
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<span>{{ $t("nomadnet.pop_out_browser") }}</span>
@@ -3,12 +3,9 @@
v-if="config"
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<!-- hero card -->
<div
class="bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl p-5 md:p-6"
>
<div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-8 py-4 sm:py-6">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
<div class="settings-section settings-section--hero">
<div class="flex flex-col md:flex-row md:items-center gap-4">
<div class="flex-1 space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -39,9 +36,11 @@
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
</div>
</transition>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4 text-sm text-gray-600 dark:text-gray-300">
<div
class="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3 mt-4 text-sm text-gray-600 dark:text-gray-300"
>
<div
class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70"
class="border border-gray-200/70 dark:border-zinc-800/80 py-3 px-3 sm:rounded-xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02]"
>
<div class="text-xs uppercase tracking-wide">{{ $t("app.theme") }}</div>
<div class="font-semibold text-gray-900 dark:text-white capitalize">
@@ -49,7 +48,7 @@
</div>
</div>
<div
class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70"
class="border border-gray-200/70 dark:border-zinc-800/80 py-3 px-3 sm:rounded-xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02]"
>
<div class="text-xs uppercase tracking-wide">{{ $t("app.transport") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
@@ -57,7 +56,7 @@
</div>
</div>
<div
class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70"
class="border border-gray-200/70 dark:border-zinc-800/80 py-3 px-3 sm:rounded-xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02]"
>
<div class="text-xs uppercase tracking-wide">{{ $t("app.propagation") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
@@ -98,8 +97,10 @@
</div>
<!-- search bar -->
<div class="sticky top-0 z-10 py-4">
<div class="relative max-w-6xl mx-auto">
<div
class="sticky top-0 z-10 py-3 sm:py-4 -mx-3 sm:mx-0 px-3 sm:px-0 mb-2 bg-gradient-to-br from-slate-50/95 via-slate-100/95 to-white/95 dark:from-zinc-950/95 dark:via-zinc-900/95 dark:to-zinc-900/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-zinc-800/50"
>
<div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] 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>
@@ -140,22 +141,25 @@
</div>
<!-- settings grid -->
<div v-show="hasSearchResults" class="columns-1 lg:columns-2 gap-4 space-y-4">
<div
v-show="hasSearchResults"
class="columns-1 md:columns-2 xl:columns-2 2xl:columns-3 gap-x-8 gap-y-0"
>
<!-- Banishment -->
<section
v-show="
matchesSearch('stranger', 'attachments', 'trust', 'block', 'banner', 'unknown', 'contact')
"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Security</div>
<div class="settings-section__eyebrow">Security</div>
<h2>{{ $t("app.stranger_protection") }}</h2>
<p>{{ $t("app.stranger_protection_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<label class="setting-toggle">
<Toggle
id="block-attachments-from-strangers"
@@ -204,16 +208,16 @@
<section
v-show="matchesSearch(...sectionKeywords.banishment)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Visuals</div>
<div class="settings-section__eyebrow">Visuals</div>
<h2>{{ $t("app.banishment") }}</h2>
<p>{{ $t("app.banishment_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<label class="setting-toggle">
<Toggle
id="banished-effect-enabled"
@@ -273,16 +277,16 @@
<!-- Maintenance & Data -->
<section
v-show="matchesSearch(...sectionKeywords.maintenance)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Maintenance</div>
<div class="settings-section__eyebrow">Maintenance</div>
<h2>{{ $t("maintenance.title") }}</h2>
<p>{{ $t("maintenance.description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="grid grid-cols-1 gap-3">
<button
type="button"
@@ -291,7 +295,7 @@
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="message-remove" class="size-4" />
<MaterialDesignIcon icon-name="forum-remove-outline" class="size-4" />
{{ $t("maintenance.clear_messages") }}
</div>
<div class="text-xs opacity-80">
@@ -438,7 +442,7 @@
@click="exportFolders"
>
<MaterialDesignIcon
icon-name="folder-export-outline"
icon-name="folder-download-outline"
class="size-6 text-purple-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">Export Folders</div>
@@ -450,7 +454,7 @@
@click="triggerFolderImport"
>
<MaterialDesignIcon
icon-name="folder-import-outline"
icon-name="folder-upload-outline"
class="size-6 text-indigo-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">Import Folders</div>
@@ -470,16 +474,16 @@
<section
v-if="ElectronUtils.isElectron()"
v-show="matchesSearch(...sectionKeywords.desktop)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Desktop</div>
<div class="settings-section__eyebrow">Desktop</div>
<h2>App Behaviour</h2>
<p>Control how MeshChat behaves on your desktop.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<label class="setting-toggle opacity-50 cursor-not-allowed">
<Toggle
id="desktop-open-calls-in-separate-window"
@@ -517,15 +521,18 @@
</section>
<!-- Page Archiver -->
<section v-show="matchesSearch(...sectionKeywords.archiver)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section
v-show="matchesSearch(...sectionKeywords.archiver)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Browsing</div>
<div class="settings-section__eyebrow">Browsing</div>
<h2>Page Archiver</h2>
<p>Automatically save copies of visited NomadNetwork pages.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<div class="settings-section__body space-y-3">
<label class="setting-toggle">
<Toggle
id="page-archiver-enabled"
@@ -584,15 +591,18 @@
</section>
<!-- Smart Crawler -->
<section v-show="matchesSearch(...sectionKeywords.crawler)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section
v-show="matchesSearch(...sectionKeywords.crawler)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Discovery</div>
<div class="settings-section__eyebrow">Discovery</div>
<h2>Smart Crawler</h2>
<p>Automatically archive node homepages when announced.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<label class="setting-toggle">
<Toggle
id="crawler-enabled"
@@ -661,16 +671,16 @@
<!-- Appearance -->
<section
v-show="matchesSearch(...sectionKeywords.appearance)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Personalise</div>
<div class="settings-section__eyebrow">Personalise</div>
<h2>{{ $t("app.appearance") }}</h2>
<p>{{ $t("app.appearance_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.theme") }}
@@ -849,10 +859,10 @@
</div>
<div class="flex gap-2">
<input
v-if="config.message_inbound_bubble_color"
v-model="config.message_inbound_bubble_color"
type="color"
class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
:disabled="!config.message_inbound_bubble_color"
@input="onMessageBubbleColorChange('inbound')"
/>
<div
@@ -884,15 +894,18 @@
</section>
<!-- Location -->
<section v-show="matchesSearch(...sectionKeywords.location)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section
v-show="matchesSearch(...sectionKeywords.location)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
<div class="settings-section__eyebrow">Privacy</div>
<h2>Location</h2>
<p>Manage how your location is shared.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Location Source</div>
<select
@@ -1054,16 +1067,16 @@
'Русский'
)
"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">i18n</div>
<div class="settings-section__eyebrow">i18n</div>
<h2>{{ $t("app.language") }}</h2>
<p>{{ $t("app.select_language") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<div class="settings-section__body space-y-3">
<select v-model="config.language" class="input-field" @change="onLanguageChange">
<option value="en">English</option>
<option value="de">Deutsch</option>
@@ -1084,16 +1097,16 @@
'app.announce_limits'
)
"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">RNS Security</div>
<div class="settings-section__eyebrow">RNS Security</div>
<h2>Network Security</h2>
<p>Manage mesh-level security features.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="setting-toggle">
<div class="setting-toggle__label">
<div class="setting-toggle__title">
@@ -1237,15 +1250,18 @@
</section>
<!-- Transport -->
<section v-show="matchesSearch(...sectionKeywords.transport)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section
v-show="matchesSearch(...sectionKeywords.transport)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Reticulum</div>
<div class="settings-section__eyebrow">Reticulum</div>
<h2>{{ $t("app.transport_mode") }}</h2>
<p>{{ $t("app.transport_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<div class="settings-section__body space-y-3">
<label class="setting-toggle">
<Toggle
id="transport-enabled"
@@ -1266,16 +1282,16 @@
<!-- Interfaces -->
<section
v-show="matchesSearch(...sectionKeywords.interfaces)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Adapters</div>
<div class="settings-section__eyebrow">Adapters</div>
<h2>{{ $t("app.interfaces") }}</h2>
<p>Show curated community configs inside the interface wizard.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<div class="settings-section__body space-y-3">
<label class="setting-toggle">
<Toggle
id="show-community-interfaces"
@@ -1295,17 +1311,17 @@
<!-- Blocked -->
<section
v-show="matchesSearch('Privacy', 'Banished', 'Manage Banished users and nodes')"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
<div class="settings-section__eyebrow">Privacy</div>
<h2>Banished</h2>
<p>Manage Banished users and nodes</p>
</div>
<RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Banished </RouterLink>
</header>
<div class="glass-card__body">
<div class="settings-section__body">
<p class="text-sm text-gray-600 dark:text-gray-400">
Banished users and nodes will not be able to send you messages, and their announces will
be ignored.
@@ -1314,15 +1330,18 @@
</section>
<!-- Authentication -->
<section v-show="matchesSearch(...sectionKeywords.auth)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section
v-show="matchesSearch(...sectionKeywords.auth)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Security</div>
<div class="settings-section__eyebrow">Security</div>
<h2>Authentication</h2>
<p>Require a password to access the web interface.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<div class="settings-section__body space-y-3">
<label class="setting-toggle">
<Toggle
id="auth-enabled"
@@ -1348,16 +1367,16 @@
<!-- Translator -->
<section
v-show="matchesSearch(...sectionKeywords.translator)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">i18n</div>
<div class="settings-section__eyebrow">i18n</div>
<h2>{{ $t("app.translator") }}</h2>
<p>{{ $t("translator.description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<label class="setting-toggle">
<Toggle
id="translator-enabled"
@@ -1393,16 +1412,16 @@
<!-- Sources & Infrastructure -->
<section
v-show="matchesSearch(...sectionKeywords.infrastructure)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Infrastructure</div>
<div class="settings-section__eyebrow">Infrastructure</div>
<h2>Sources & Mirroring</h2>
<p>Customize URLs for documentation and external resources.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Gitea Base URL</div>
<input
@@ -1435,15 +1454,15 @@
</section>
<!-- Content Security Policy (CSP) -->
<section v-show="matchesSearch(...sectionKeywords.csp)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section v-show="matchesSearch(...sectionKeywords.csp)" class="settings-section break-inside-avoid">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">Security</div>
<div class="settings-section__eyebrow">Security</div>
<h2>{{ $t("app.csp_settings") }}</h2>
<p>{{ $t("app.csp_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.csp_extra_connect_src") }}
@@ -1527,15 +1546,18 @@
</section>
<!-- Messages -->
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section
v-show="matchesSearch(...sectionKeywords.messages)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">{{ $t("app.reliability") }}</div>
<div class="settings-section__eyebrow">{{ $t("app.reliability") }}</div>
<h2>{{ $t("app.messages") }}</h2>
<p>{{ $t("app.messages_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<div class="settings-section__body space-y-3">
<label class="setting-toggle">
<Toggle
id="auto-resend-failed"
@@ -1613,11 +1635,11 @@
<!-- Propagation nodes -->
<section
v-show="matchesSearch(...sectionKeywords.propagation)"
class="glass-card break-inside-avoid"
class="settings-section break-inside-avoid"
>
<header class="glass-card__header">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">LXMF</div>
<div class="settings-section__eyebrow">LXMF</div>
<h2>{{ $t("app.propagation_nodes") }}</h2>
<p>{{ $t("app.propagation_nodes_description") }}</p>
</div>
@@ -1625,7 +1647,7 @@
{{ $t("app.browse_nodes") }}
</RouterLink>
</header>
<div class="glass-card__body space-y-5">
<div class="settings-section__body space-y-5">
<div class="info-callout">
<ul class="list-disc list-inside space-y-1 text-sm">
<li>{{ $t("app.nodes_info_1") }}</li>
@@ -1728,15 +1750,15 @@
<!-- System / RNS Reload -->
<!--
<section class="glass-card break-inside-avoid">
<header class="glass-card__header">
<section class="settings-section break-inside-avoid">
<header class="settings-section__header">
<div>
<div class="glass-card__eyebrow">{{ $t("app.system") }}</div>
<div class="settings-section__eyebrow">{{ $t("app.system") }}</div>
<h2>{{ $t("app.reticulum_stack") }}</h2>
<p>{{ $t("app.reticulum_stack_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="settings-section__body space-y-4">
<div class="flex flex-col gap-3">
<button
class="btn btn--secondary w-full justify-center gap-2 py-3"
@@ -1761,8 +1783,8 @@
<!-- Keyboard Shortcuts (Full width at bottom) -->
<div v-show="matchesSearch(...sectionKeywords.shortcuts)" class="mt-4">
<section class="glass-card">
<div class="glass-card__header">
<section class="settings-section">
<div class="settings-section__header">
<div class="flex items-center gap-3">
<div
class="p-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-xl"
@@ -1775,7 +1797,7 @@
</div>
</div>
</div>
<div class="glass-card__body">
<div class="settings-section__body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="shortcut in KeyboardShortcuts.getDefaultShortcuts()"
@@ -2123,6 +2145,7 @@ export default {
case "config": {
if (json.config) {
this.config = { ...this.config, ...json.config };
this.sanitizeColorConfigFields();
}
break;
}
@@ -2137,6 +2160,7 @@ export default {
const response = await window.api.get("/api/v1/config");
if (response?.data?.config) {
this.config = { ...this.config, ...response.data.config };
this.sanitizeColorConfigFields();
const inbound = Number(this.config.lxmf_inbound_stamp_cost);
if (inbound > 0) {
this.lastRememberedInboundStampCost = Math.min(254, inbound);
@@ -2175,6 +2199,7 @@ export default {
try {
const response = await window.api.patch("/api/v1/config", config);
this.config = response.data.config;
this.sanitizeColorConfigFields();
if (label) {
ToastUtils.success(this.$t("app.setting_auto_saved", { label: this.$t(`app.${label}`) }));
}
@@ -2190,6 +2215,29 @@ export default {
const n = Number(v);
return Number.isFinite(n) ? n : null;
},
sanitizeColorConfigFields() {
if (!this.config) return;
const hex6 = (value, fallback) => {
if (value == null || value === "") {
return fallback;
}
if (typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value.trim())) {
return value.trim();
}
return fallback;
};
this.config.banished_color = hex6(this.config.banished_color, "#dc2626");
this.config.message_outbound_bubble_color = hex6(this.config.message_outbound_bubble_color, "#4f46e5");
this.config.message_failed_bubble_color = hex6(this.config.message_failed_bubble_color, "#ef4444");
const inbound = this.config.message_inbound_bubble_color;
if (inbound == null || inbound === "") {
this.config.message_inbound_bubble_color = null;
} else if (typeof inbound === "string" && /^#[0-9A-Fa-f]{6}$/.test(inbound.trim())) {
this.config.message_inbound_bubble_color = inbound.trim();
} else {
this.config.message_inbound_bubble_color = null;
}
},
async onAnnounceLimitsChange() {
const c = this.config;
await this.updateConfig(
@@ -2793,23 +2841,26 @@ export default {
</script>
<style scoped>
.glass-card {
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg flex flex-col;
.settings-section {
@apply w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8 flex flex-col break-inside-avoid;
}
.glass-card__header {
@apply flex items-center justify-between gap-3 px-4 py-4 border-b border-gray-100/70 dark:border-zinc-800/80;
.settings-section--hero {
@apply border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
}
.glass-card__header h2 {
.settings-section__header {
@apply flex items-center justify-between gap-3 pb-4 border-b border-gray-100/60 dark:border-zinc-800/60;
}
.settings-section__header h2 {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.glass-card__header p {
.settings-section__header p {
@apply text-sm text-gray-600 dark:text-gray-400;
}
.glass-card__eyebrow {
.settings-section__eyebrow {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.glass-card__body {
@apply px-4 py-4 text-gray-900 dark:text-gray-100;
.settings-section__body {
@apply pt-4 text-gray-900 dark:text-gray-100;
}
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
@@ -2842,7 +2893,7 @@ export default {
font-family: "Roboto Mono", monospace;
}
.address-card {
@apply relative border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white/80 dark:bg-zinc-900/70 p-4 space-y-2;
@apply relative border border-gray-200/70 dark:border-zinc-800/80 py-3 px-3 sm:rounded-xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02] space-y-2;
}
.address-card__label {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
+1
View File
@@ -536,6 +536,7 @@
},
"map": {
"title": "Karte",
"pop_out": "Karte ausdocken",
"description": "Offline-fähige Karte mit MBTiles-Unterstützung.",
"upload_mbtiles": "MBTiles hochladen",
"select_file": "MBTiles-Datei auswählen",
+1
View File
@@ -490,6 +490,7 @@
},
"map": {
"title": "Map",
"pop_out": "Pop out map",
"description": "Offline-capable map with MBTiles support.",
"upload_mbtiles": "Upload MBTiles",
"select_file": "Select MBTiles file",
+1
View File
@@ -536,6 +536,7 @@
},
"map": {
"title": "Mappa",
"pop_out": "Apri mappa in finestra",
"description": "Mappa compatibile con l'uso offline e supporto MBTiles.",
"upload_mbtiles": "Carica MBTiles",
"select_file": "Seleziona file MBTiles",
+1
View File
@@ -536,6 +536,7 @@
},
"map": {
"title": "Карта",
"pop_out": "Открыть карту в окне",
"description": "Офлайн-карта с поддержкой MBTiles.",
"upload_mbtiles": "Загрузить MBTiles",
"select_file": "Выбрать файл MBTiles",
+6
View File
@@ -117,6 +117,12 @@ const router = createRouter({
path: "/map",
component: defineAsyncComponent(() => import("./components/map/MapPage.vue")),
},
{
name: "map-popout",
path: "/popout/map",
meta: { popoutType: "map", isPopout: true },
component: defineAsyncComponent(() => import("./components/map/MapPage.vue")),
},
{
name: "messages-popout",
path: "/popout/messages/:destinationHash?",