feat(frontend): big updates (too many)

This commit is contained in:
2026-01-04 12:41:34 -06:00
parent 6a61441e73
commit 4507a999fc
21 changed files with 1474 additions and 851 deletions

View File

@@ -4,34 +4,37 @@
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="$t('app.language')"
@click="toggleDropdown"
@click.stop="toggleDropdown"
>
<MaterialDesignIcon icon-name="translate" class="w-5 h-5 sm:w-6 sm:h-6" />
</button>
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
>
<div class="p-2">
<button
v-for="lang in languages"
:key="lang.code"
type="button"
class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
:class="{
'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
currentLanguage === lang.code,
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
}"
@click="selectLanguage(lang.code)"
>
<span class="font-medium">{{ lang.name }}</span>
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
</button>
<Teleport to="body">
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
:style="dropdownStyle"
>
<div class="p-2">
<button
v-for="lang in languages"
:key="lang.code"
type="button"
class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
:class="{
'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
currentLanguage === lang.code,
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
}"
@click="selectLanguage(lang.code)"
>
<span class="font-medium">{{ lang.name }}</span>
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -62,6 +65,7 @@ export default {
data() {
return {
isDropdownOpen: false,
dropdownPosition: { top: 0, left: 0 },
languages: [
{ code: "en", name: "English" },
{ code: "de", name: "Deutsch" },
@@ -73,10 +77,27 @@ export default {
currentLanguage() {
return this.$i18n.locale;
},
dropdownStyle() {
return {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
},
},
methods: {
toggleDropdown() {
toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
this.updateDropdownPosition(event);
}
},
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
this.dropdownPosition = {
top: rect.bottom + 8,
left: Math.max(8, rect.right - 192), // 192px is w-48
};
},
closeDropdown() {
this.isDropdownOpen = false;

View File

@@ -16,10 +16,10 @@
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-[15%] rounded-full shrink-0 flex items-center justify-center"
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
:class="iconClass || 'size-6'"
>
<MaterialDesignIcon icon-name="account-outline" class="w-full h-full" />
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
</div>
</template>

View File

@@ -3,7 +3,7 @@
<button
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
@click="toggleDropdown"
@click.stop="toggleDropdown"
>
<MaterialDesignIcon icon-name="bell" class="w-5 h-5 sm:w-6 sm:h-6" />
<span
@@ -14,96 +14,103 @@
</span>
</button>
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
class="absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="closeDropdown"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</button>
</div>
</div>
<div class="overflow-y-auto flex-1">
<div v-if="isLoading" class="p-8 text-center">
<div class="inline-block animate-spin text-gray-400">
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
<Teleport to="body">
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
class="fixed w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
:style="dropdownStyle"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="closeDropdown"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</button>
</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
</div>
<div v-else-if="notifications.length === 0" class="p-8 text-center">
<MaterialDesignIcon
icon-name="bell-off"
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
/>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
</div>
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
<button
v-for="notification in notifications"
:key="notification.destination_hash"
type="button"
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
@click="onNotificationClick(notification)"
>
<div class="flex gap-3">
<div class="flex-shrink-0">
<div
v-if="notification.lxmf_user_icon"
class="p-2 rounded-lg"
:style="{
color: notification.lxmf_user_icon.foreground_colour,
'background-color': notification.lxmf_user_icon.background_colour,
}"
>
<MaterialDesignIcon
:icon-name="notification.lxmf_user_icon.icon_name"
class="w-6 h-6"
/>
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
>
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2 mb-1">
<div
class="font-semibold text-gray-900 dark:text-white truncate"
:title="notification.custom_display_name ?? notification.display_name"
>
{{ notification.custom_display_name ?? notification.display_name }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
>
{{ formatTimeAgo(notification.updated_at) }}
</div>
</div>
<div
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
:title="notification.latest_message_preview ?? notification.content ?? 'No preview'"
>
{{ notification.latest_message_preview ?? notification.content ?? "No preview" }}
</div>
</div>
<div class="overflow-y-auto flex-1">
<div v-if="isLoading" class="p-8 text-center">
<div class="inline-block animate-spin text-gray-400">
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
</div>
</button>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
</div>
<div v-else-if="notifications.length === 0" class="p-8 text-center">
<MaterialDesignIcon
icon-name="bell-off"
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
/>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
</div>
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
<button
v-for="notification in notifications"
:key="notification.destination_hash"
type="button"
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
@click="onNotificationClick(notification)"
>
<div class="flex gap-3">
<div class="flex-shrink-0">
<div
v-if="notification.lxmf_user_icon"
class="p-2 rounded-lg"
:style="{
color: notification.lxmf_user_icon.foreground_colour,
'background-color': notification.lxmf_user_icon.background_colour,
}"
>
<MaterialDesignIcon
:icon-name="notification.lxmf_user_icon.icon_name"
class="w-6 h-6"
/>
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
>
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2 mb-1">
<div
class="font-semibold text-gray-900 dark:text-white truncate"
:title="notification.custom_display_name ?? notification.display_name"
>
{{ notification.custom_display_name ?? notification.display_name }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
>
{{ formatTimeAgo(notification.updated_at) }}
</div>
</div>
<div
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
:title="
notification.latest_message_preview ?? notification.content ?? 'No preview'
"
>
{{
notification.latest_message_preview ?? notification.content ?? "No preview"
}}
</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -139,9 +146,17 @@ export default {
notifications: [],
unreadCount: 0,
reloadInterval: null,
dropdownPosition: { top: 0, left: 0 },
};
},
computed: {},
computed: {
dropdownStyle() {
return {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
},
},
beforeUnmount() {
if (this.reloadInterval) {
clearInterval(this.reloadInterval);
@@ -158,13 +173,25 @@ export default {
}, 5000);
},
methods: {
async toggleDropdown() {
async toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
this.updateDropdownPosition(event);
await this.loadNotifications();
await this.markNotificationsAsViewed();
}
},
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const isMobile = window.innerWidth < 640;
const dropdownWidth = isMobile ? 320 : 384; // 80 (320px) or 96 (384px)
this.dropdownPosition = {
top: rect.bottom + 8,
left: Math.max(16, rect.right - dropdownWidth),
};
},
closeDropdown() {
this.isDropdownOpen = false;
},

View File

File diff suppressed because it is too large Load Diff

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">Blocked</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage blocked users and nodes</p>
<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>
</div>
</div>
@@ -39,7 +39,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 blocked items...</p>
<p class="text-gray-500 dark:text-gray-400">Loading banished items...</p>
</div>
<div
@@ -49,12 +49,12 @@
<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 blocked items</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No banished items</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{{
searchQuery
? "No blocked items match your search."
: "You haven't blocked any users or nodes yet."
? "No banished items match your search."
: "You haven't banished any users or nodes yet."
}}
</p>
</div>
@@ -95,6 +95,13 @@
>
User
</span>
<span
v-if="item.is_rns_blackholed"
class="px-2 py-0.5 text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 rounded border border-zinc-200 dark:border-zinc-700"
title="Blackholed at Reticulum transport layer"
>
RNS Blackhole
</span>
</div>
<p
class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
@@ -104,8 +111,20 @@
</p>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Blocked {{ formatTimeAgo(item.created_at) }}
<div v-if="item.created_at" class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Banished {{ formatTimeAgo(item.created_at) }}
</div>
<div
v-if="item.rns_source"
class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
>
Source: {{ item.rns_source }}
</div>
<div
v-if="item.rns_reason"
class="text-xs italic text-zinc-500 dark:text-zinc-400 mb-2"
>
"{{ item.rns_reason }}"
</div>
</div>
</div>
@@ -114,7 +133,7 @@
@click="onUnblock(item)"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
<span>Unblock</span>
<span>Lift Banishment</span>
</button>
</div>
</div>
@@ -137,17 +156,31 @@ export default {
data() {
return {
blockedItems: [],
reticulumBlackholedItems: [],
isLoading: false,
searchQuery: "",
};
},
computed: {
allBlockedItems() {
// Combine local blocked items and reticulum blackholed items
// Prioritize local items if they overlap
const localHashes = new Set(this.blockedItems.map((i) => i.destination_hash));
const combined = [...this.blockedItems];
for (const item of this.reticulumBlackholedItems) {
if (!localHashes.has(item.destination_hash)) {
combined.push(item);
}
}
return combined;
},
filteredBlockedItems() {
if (!this.searchQuery.trim()) {
return this.blockedItems;
return this.allBlockedItems;
}
const query = this.searchQuery.toLowerCase();
return this.blockedItems.filter((item) => {
return this.allBlockedItems.filter((item) => {
const matchesHash = item.destination_hash.toLowerCase().includes(query);
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
return matchesHash || matchesDisplayName;
@@ -161,74 +194,70 @@ export default {
async loadBlockedDestinations() {
this.isLoading = true;
try {
// Load local blocked destinations
const response = await window.axios.get("/api/v1/blocked-destinations");
const blockedHashes = response.data.blocked_destinations || [];
const items = await Promise.all(
blockedHashes.map(async (blocked) => {
let displayName = "Unknown";
let isNode = false;
// Load Reticulum blackholed identities
let reticulumBlackholed = {};
try {
const rnsResponse = await window.axios.get("/api/v1/reticulum/blackhole");
reticulumBlackholed = rnsResponse.data.blackholed_identities || {};
} catch (e) {
console.error("Failed to load Reticulum blackhole", e);
}
try {
const nodeAnnounceResponse = await window.axios.get("/api/v1/announces", {
params: {
aspect: "nomadnetwork.node",
identity_hash: blocked.destination_hash,
include_blocked: true,
limit: 1,
},
});
const processItem = async (hash, data = {}) => {
let displayName = "Unknown";
let isNode = false;
if (nodeAnnounceResponse.data.announces && nodeAnnounceResponse.data.announces.length > 0) {
const announce = nodeAnnounceResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = true;
} else {
const announceResponse = await window.axios.get("/api/v1/announces", {
params: {
identity_hash: blocked.destination_hash,
include_blocked: true,
limit: 1,
},
});
try {
const announceResponse = await window.axios.get("/api/v1/announces", {
params: {
identity_hash: hash,
include_blocked: true,
limit: 1,
},
});
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
const announce = announceResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = announce.aspect === "nomadnetwork.node";
} else {
const lxmfResponse = await window.axios.get("/api/v1/announces", {
params: {
destination_hash: blocked.destination_hash,
include_blocked: true,
limit: 1,
},
});
if (lxmfResponse.data.announces && lxmfResponse.data.announces.length > 0) {
const announce = lxmfResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = announce.aspect === "nomadnetwork.node";
}
}
}
} catch (e) {
console.log(e);
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
const announce = announceResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = announce.aspect === "nomadnetwork.node";
}
} catch {
// ignore error
}
return {
destination_hash: blocked.destination_hash,
display_name: displayName,
created_at: blocked.created_at,
is_node: isNode,
};
})
return {
destination_hash: hash,
display_name: displayName,
created_at: data.created_at || null,
is_node: isNode,
is_rns_blackholed: !!data.is_rns,
rns_source: data.source || null,
rns_reason: data.reason || null,
rns_until: data.until || null,
};
};
const items = await Promise.all(
blockedHashes.map((blocked) =>
processItem(blocked.destination_hash, { created_at: blocked.created_at })
)
);
const rnsItems = await Promise.all(
Object.entries(reticulumBlackholed).map(([hash, info]) =>
processItem(hash, { ...info, is_rns: true })
)
);
this.blockedItems = items;
this.reticulumBlackholedItems = rnsItems;
} catch (e) {
console.log(e);
ToastUtils.error("Failed to load blocked destinations");
ToastUtils.error("Failed to load banished destinations");
} finally {
this.isLoading = false;
}
@@ -236,7 +265,7 @@ export default {
async onUnblock(item) {
if (
!(await DialogUtils.confirm(
`Are you sure you want to unblock ${item.display_name || item.destination_hash}?`
`Are you sure you want to lift the banishment for ${item.display_name || item.destination_hash}?`
))
) {
return;
@@ -245,10 +274,10 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
await this.loadBlockedDestinations();
ToastUtils.success("Unblocked successfully");
ToastUtils.success("Banishment lifted successfully");
} catch (e) {
console.log(e);
ToastUtils.error("Failed to unblock");
ToastUtils.error("Failed to lift banishment");
}
},
onSearchInput() {},

View File

@@ -2380,15 +2380,15 @@ export default {
}
},
async blockIdentity(hash) {
if (!confirm(`Are you sure you want to block this identity?`)) return;
if (!confirm(`Are you sure you want to banish this identity?`)) return;
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: hash,
});
ToastUtils.success("Identity blocked");
ToastUtils.success("Identity banished");
this.getHistory();
} catch {
ToastUtils.error("Failed to block identity");
ToastUtils.error("Failed to banish identity");
}
},
async getVoicemailStatus() {

View File

@@ -1588,12 +1588,6 @@ export default {
.glass-label {
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
}
.primary-chip {
@apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
}
.secondary-chip {
@apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400;
}
.glass-field {
@apply space-y-1;
}

View File

@@ -78,12 +78,12 @@
<!-- drawing toolbar -->
<div class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu">
<div
class="bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
>
<button
v-for="tool in drawingTools"
:key="tool.type"
class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
@@ -92,11 +92,11 @@
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
>
<v-icon :icon="'mdi-' + tool.icon" size="22"></v-icon>
<v-icon :icon="'mdi-' + tool.icon" size="20"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
@@ -105,37 +105,37 @@
:title="$t('map.tool_measure')"
@click="toggleMeasure"
>
<v-icon icon="mdi-ruler" size="22"></v-icon>
<v-icon icon="mdi-ruler" size="20"></v-icon>
</button>
<button
class="p-2.5 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
class="p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@click="clearDrawings"
>
<v-icon icon="mdi-trash-can-outline" size="22"></v-icon>
<v-icon icon="mdi-trash-can-outline" size="20"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.save_drawing')"
@click="showSaveDrawingModal = true"
>
<v-icon icon="mdi-content-save-outline" size="22"></v-icon>
<v-icon icon="mdi-content-save-outline" size="20"></v-icon>
</button>
<button
class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.load_drawing')"
@click="openLoadDrawingModal"
>
<v-icon icon="mdi-folder-open-outline" size="22"></v-icon>
<v-icon icon="mdi-folder-open-outline" size="20"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
class="p-2.5 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
class="p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.go_to_my_location')"
@click="goToMyLocation"
>
<v-icon icon="mdi-crosshairs-gps" size="22"></v-icon>
<v-icon icon="mdi-crosshairs-gps" size="20"></v-icon>
</button>
</div>
</div>
@@ -147,9 +147,7 @@
class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-30"
>
<div class="relative">
<div
class="flex items-center bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-xl shadow-2xl border-0 ring-0"
>
<div class="flex items-center bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 ring-0">
<input
v-model="searchQuery"
type="text"
@@ -237,8 +235,8 @@
Edit Note
</span>
<button
@click="closeNoteEditor"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeNoteEditor"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
@@ -250,15 +248,15 @@
></textarea>
<div class="flex justify-between mt-3">
<button
@click="deleteNote"
class="px-3 py-1.5 text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors flex items-center gap-1"
@click="deleteNote"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
Delete
</button>
<button
@click="saveNote"
class="px-3 py-1.5 text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 rounded-lg shadow-sm transition-colors"
@click="saveNote"
>
Save
</button>
@@ -922,8 +920,8 @@
Edit Note
</h3>
<button
@click="closeNoteEditor"
class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
@click="closeNoteEditor"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
@@ -938,15 +936,15 @@
</div>
<div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
<button
@click="deleteNote"
class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
@click="deleteNote"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
Delete
</button>
<button
@click="saveNote"
class="flex-[2] px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
@click="saveNote"
>
Save Note
</button>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="audio-waveform-player flex items-center gap-3 p-2 rounded-xl transition-all w-full min-w-[240px]"
class="audio-waveform-player flex items-center gap-3 p-2 rounded-xl transition-all w-full min-w-0"
:class="[
isOutbound ? 'bg-white/10 text-white' : 'bg-gray-100 dark:bg-zinc-800/80 text-gray-800 dark:text-zinc-200',
]"

View File

@@ -28,11 +28,11 @@
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
<MaterialDesignIcon icon-name="block-helper" class="size-5 text-red-500" />
<span class="text-red-500">Block User</span>
<span class="text-red-500">Banish User</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click="onUnblockDestination">
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
<span class="text-green-500">Unblock User</span>
<span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</div>
@@ -83,7 +83,7 @@ export default {
async onBlockDestination() {
if (
!(await DialogUtils.confirm(
"Are you sure you want to block this user? They will not be able to send you messages or establish links."
"Are you sure you want to banish this user? They will not be able to send you messages or establish links."
))
) {
return;
@@ -94,10 +94,10 @@ export default {
destination_hash: this.peer.destination_hash,
});
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert("User blocked successfully");
DialogUtils.alert("User banished successfully");
this.$emit("block-status-changed");
} catch (e) {
DialogUtils.alert("Failed to block user");
DialogUtils.alert("Failed to banish user");
console.log(e);
}
},
@@ -105,10 +105,10 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert("User unblocked successfully");
DialogUtils.alert("Banishment lifted successfully");
this.$emit("block-status-changed");
} catch (e) {
DialogUtils.alert("Failed to unblock user");
DialogUtils.alert("Failed to lift banishment");
console.log(e);
}
},

View File

@@ -50,7 +50,7 @@
<MaterialDesignIcon icon-name="tag-outline" class="size-4" />
</div>
<div
class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-xs sm:max-w-sm text-base"
class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[120px] sm:max-w-sm text-base"
:title="selectedPeer.custom_display_name ?? selectedPeer.display_name"
>
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
@@ -193,16 +193,16 @@
class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50"
@scroll="onMessagesScroll"
>
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6">
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6 min-w-0">
<div
v-for="chatItem of selectedPeerChatItemsReversed"
:key="chatItem.lxmf_message.hash"
class="flex flex-col max-w-[75%] sm:max-w-[65%] lg:max-w-[55%] mb-4 group"
class="flex flex-col max-w-[85%] sm:max-w-[75%] lg:max-w-[65%] mb-4 group min-w-0"
:class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }"
>
<!-- message content -->
<div
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md"
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0"
:class="[
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
? 'bg-red-500 text-white shadow-sm'
@@ -214,7 +214,7 @@
]"
@click="onChatItemClick(chatItem)"
>
<div class="w-full space-y-1 px-4 py-2.5">
<div class="w-full space-y-1 px-4 py-2.5 min-w-0">
<!-- spam badge -->
<div
v-if="chatItem.lxmf_message.is_spam"
@@ -243,7 +243,7 @@
<!-- content -->
<div
v-if="chatItem.lxmf_message.content"
class="leading-relaxed whitespace-pre-wrap break-words"
class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
:style="{
'font-family': 'inherit',
'font-size': (config?.message_font_size || 14) + 'px',
@@ -296,14 +296,14 @@
<!-- paper message auto-conversion -->
<div
v-if="getParsedItems(chatItem).paperMessage"
class="flex flex-col gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800/30"
v-if="getParsedItems(chatItem).paperMessage && !chatItem.is_outbound"
class="flex flex-col gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-black/60 border border-emerald-100 dark:border-zinc-700/50"
>
<div class="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
<div class="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
<span class="text-sm font-bold">Paper Message detected</span>
</div>
<p class="text-xs text-emerald-600/80 dark:text-emerald-400/80 leading-relaxed">
<p class="text-xs text-emerald-600/80 dark:text-zinc-400 leading-relaxed">
This message contains a signed LXMF URI that can be ingested into your
conversations.
</p>
@@ -321,7 +321,7 @@
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group mt-1 -mx-1">
<img
:src="`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/image`"
class="w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"
class="max-w-[240px] sm:max-w-xs w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"
@click.stop="
openImage(
`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/image`
@@ -636,6 +636,7 @@
<div
v-for="(line, index) in getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound)"
:key="index"
class="break-all"
>
{{ line }}
</div>
@@ -674,7 +675,7 @@
class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5"
>
<div class="w-full">
<!-- blocked user notification -->
<!-- banished user notification -->
<div
v-if="isSelectedPeerBlocked"
class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2"
@@ -694,7 +695,7 @@
/>
</svg>
<span class="text-sm text-yellow-800 dark:text-yellow-200"
>You have blocked this user. They cannot send you messages or establish links.</span
>You have banished this user. They cannot send you messages or establish links.</span
>
</div>
@@ -914,13 +915,27 @@
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
@click="$emit('update:selectedPeer', chat)"
>
<LxmfUserIcon
:custom-image="chat.contact_image"
:icon-name="chat.lxmf_user_icon ? chat.lxmf_user_icon.icon_name : ''"
:icon-foreground-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''"
:icon-background-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''"
icon-class="size-10"
/>
<div class="flex-shrink-0">
<LxmfUserIcon
:custom-image="chat.contact_image"
:icon-name="
chat.lxmf_user_icon && chat.lxmf_user_icon.icon_name
? chat.lxmf_user_icon.icon_name
: 'account'
"
:icon-foreground-colour="
chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour
? chat.lxmf_user_icon.foreground_colour
: ''
"
:icon-background-colour="
chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour
? chat.lxmf_user_icon.background_colour
: ''
"
icon-class="size-12 sm:size-14"
/>
</div>
<div class="flex-1 min-w-0">
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
{{ chat.custom_display_name ?? chat.display_name }}
@@ -984,12 +999,14 @@
<PaperMessageModal
v-if="isPaperMessageModalOpen"
:message-hash="paperMessageHash"
:recipient-hash="selectedPeer?.destination_hash"
@close="isPaperMessageModalOpen = false"
/>
<PaperMessageModal
v-if="isPaperMessageResultModalOpen"
:initial-uri="generatedPaperMessageUri"
:recipient-hash="selectedPeer?.destination_hash"
@close="
isPaperMessageResultModalOpen = false;
generatedPaperMessageUri = null;
@@ -2763,7 +2780,16 @@ export default {
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
}
.attachment-action-button {
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition;
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 px-3 py-1.5 text-xs font-bold text-gray-700 bg-white shadow-sm transition-all !important;
}
.attachment-action-button:hover {
@apply bg-gray-50 text-gray-900 border-blue-400 !important;
}
.dark .attachment-action-button {
@apply border-zinc-700 text-zinc-100 bg-zinc-900 !important;
}
.dark .attachment-action-button:hover {
@apply bg-zinc-800 text-white border-blue-500 !important;
}
.audio-controls-light {

View File

@@ -38,6 +38,7 @@
:my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer"
:conversations="conversations"
@update:selected-peer="onPeerClick"
@close="onCloseConversationViewer"
@reload-conversations="getConversations"
/>

View File

@@ -32,8 +32,8 @@
</div>
<template v-else-if="uri">
<!-- QR code container -->
<div class="p-4 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6 relative group">
<div class="size-48 sm:size-64 flex items-center justify-center overflow-hidden">
<div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6 relative group">
<div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
<canvas ref="qrcode"></canvas>
</div>
<div
@@ -47,7 +47,61 @@
</div>
</div>
<div class="w-full space-y-3">
<div v-if="recipientHash" class="w-full space-y-3">
<div
class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<label
class="block text-[9px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
>
LXMF URI
</label>
<div class="flex gap-2">
<div
class="flex-1 font-mono text-[10px] break-all text-gray-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 p-2 rounded-lg border border-gray-200 dark:border-zinc-700 max-h-20 overflow-y-auto"
>
{{ uri }}
</div>
<button
type="button"
class="size-9 flex items-center justify-center bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400 rounded-lg border border-gray-200 dark:border-zinc-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-all shadow-sm"
title="Copy URI"
@click="copyUri"
>
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
</button>
</div>
</div>
<div class="flex gap-2 pt-1">
<button
type="button"
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] text-sm"
@click="printQRCode"
>
<MaterialDesignIcon icon-name="printer" class="size-4" />
Print
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
:disabled="isSending"
@click="sendPaperMessage"
>
<template v-if="isSending">
<div
class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
></div>
Sending...
</template>
<template v-else>
<MaterialDesignIcon icon-name="send" class="size-4" />
Send
</template>
</button>
</div>
</div>
<div v-else class="w-full space-y-3">
<div
class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
@@ -134,12 +188,18 @@ export default {
required: false,
default: null,
},
recipientHash: {
type: String,
required: false,
default: null,
},
},
emits: ["close"],
data() {
return {
uri: this.initialUri,
isLoading: !this.initialUri,
isSending: false,
};
},
async mounted() {
@@ -173,7 +233,7 @@ export default {
try {
await QRCode.toCanvas(this.$refs.qrcode, this.uri, {
width: 320,
width: 256,
margin: 2,
color: {
dark: "#000000",
@@ -211,11 +271,56 @@ export default {
const dataUrl = canvas.toDataURL("image/png");
if (dataUrl) {
const link = document.createElement("a");
link.download = `lxmf-paper-message-${this.messageHash.substring(0, 8)}.png`;
link.download = `lxmf-paper-message-${this.messageHash ? this.messageHash.substring(0, 8) : Date.now()}.png`;
link.href = dataUrl;
link.click();
}
},
async sendPaperMessage() {
const canvas = this.$refs.qrcode;
if (!canvas || !this.recipientHash || !this.uri) return;
try {
this.isSending = true;
// get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
const dataUrl = canvas.toDataURL("image/png");
// extract base64 data by removing the prefix
const imageBytes = dataUrl.split(",")[1];
// build lxmf message
const lxmf_message = {
destination_hash: this.recipientHash,
content: this.uri,
fields: {
image: {
image_type: "png",
image_bytes: imageBytes,
name: "qrcode.png",
},
},
};
// send message
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
delivery_method: "opportunistic",
lxmf_message: lxmf_message,
});
if (response.data.status === "success") {
ToastUtils.success("Paper message sent successfully");
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");
} finally {
this.isSending = false;
}
},
printQRCode() {
const canvas = this.$refs.qrcode;
if (!canvas) return;

View File

@@ -92,7 +92,7 @@
<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">Unblock Node</span>
<span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</div>
</template>
@@ -169,7 +169,7 @@
</DropDownMenuItem>
<DropDownMenuItem v-else @click.stop="onUnblockNode(node.identity_hash)">
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
<span class="text-green-500">Unblock Node</span>
<span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
@@ -321,9 +321,9 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${identityHash}`);
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert("Node unblocked successfully");
DialogUtils.alert("Banishment lifted successfully");
} catch (e) {
DialogUtils.alert("Failed to unblock node");
DialogUtils.alert("Failed to lift banishment");
console.log(e);
}
},

View File

@@ -40,28 +40,24 @@
</div>
<div class="flex flex-wrap gap-2">
<button v-if="!isRunning" type="button" class="primary-chip px-4 py-2 text-sm" @click="start">
<button v-if="!isRunning" type="button" class="primary-chip" @click="start">
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
{{ $t("ping.start_ping") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
class="secondary-chip !text-red-600 dark:!text-red-300 !border-red-200 dark:!border-red-500/50"
@click="stop"
>
<MaterialDesignIcon icon-name="pause" class="w-4 h-4" />
{{ $t("ping.stop") }}
</button>
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="clear">
<button type="button" class="secondary-chip" @click="clear">
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
{{ $t("ping.clear_results") }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition"
@click="dropPath"
>
<button type="button" class="danger-chip" @click="dropPath">
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4" />
{{ $t("ping.drop_path") }}
</button>

View File

@@ -15,6 +15,33 @@
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("rncp.description") }}
</div>
<div
class="mt-4 p-4 rounded-lg bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20"
>
<div
class="text-xs font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 mb-2"
>
{{ $t("rncp.usage_steps") }}
</div>
<div class="space-y-1.5">
<!-- eslint-disable-next-line vue/no-v-html -->
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
v-html="renderMarkdown($t('rncp.step_1'))"
></p>
<!-- eslint-disable-next-line vue/no-v-html -->
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
v-html="renderMarkdown($t('rncp.step_2'))"
></p>
<!-- eslint-disable-next-line vue/no-v-html -->
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
v-html="renderMarkdown($t('rncp.step_3'))"
></p>
</div>
</div>
</div>
<div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
@@ -493,6 +520,10 @@ export default {
this.listenDestinationHash = null;
this.listenResult = null;
},
renderMarkdown(text) {
if (!text) return "";
return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
},
},
};
</script>

View File

@@ -48,11 +48,36 @@
</div>
</div>
<div
v-if="linkCount !== null"
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
>
<div class="font-semibold">Active Links: {{ linkCount }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-if="linkCount !== null"
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
>
<div class="font-semibold">Active Links: {{ linkCount }}</div>
</div>
<div
v-if="blackholeEnabled !== null"
class="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300"
>
<div class="font-semibold flex justify-between items-center">
<span>Blackhole: {{ blackholeEnabled ? "Publishing" : "Active" }}</span>
<span class="text-sm opacity-80"> {{ blackholeCount }} Identities </span>
</div>
</div>
</div>
</div>
<div v-if="blackholeSources.length > 0" class="glass-card space-y-3">
<div class="font-semibold text-lg text-gray-900 dark:text-white">Blackhole Sources</div>
<div class="grid gap-2">
<div
v-for="source in blackholeSources"
:key="source"
class="text-sm font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded truncate"
>
{{ source }}
</div>
</div>
</div>
@@ -189,6 +214,9 @@ export default {
linkCount: null,
includeLinkStats: false,
sorting: "",
blackholeEnabled: null,
blackholeSources: [],
blackholeCount: 0,
};
},
watch: {
@@ -215,6 +243,9 @@ export default {
const response = await window.axios.get("/api/v1/rnstatus", { params });
this.interfaces = response.data.interfaces || [];
this.linkCount = response.data.link_count;
this.blackholeEnabled = response.data.blackhole_enabled;
this.blackholeSources = response.data.blackhole_sources || [];
this.blackholeCount = response.data.blackhole_count || 0;
} catch (e) {
console.error(e);
} finally {

View File

@@ -436,6 +436,40 @@
</div>
</section>
<!-- Network Security -->
<section class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__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="setting-toggle">
<div class="setting-toggle__label">
<div class="setting-toggle__title">
{{ $t("app.blackhole_integration_enabled") }}
</div>
<div class="setting-toggle__description text-xs text-gray-500">
{{ $t("app.blackhole_integration_description") }}
</div>
</div>
<Toggle
v-model="config.blackhole_integration_enabled"
@update:model-value="
updateConfig(
{
blackhole_integration_enabled: config.blackhole_integration_enabled,
},
'blackhole_integration_enabled'
)
"
/>
</div>
</div>
</section>
<!-- Transport -->
<section class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -494,14 +528,14 @@
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
<h2>Blocked</h2>
<p>Manage blocked users and nodes</p>
<h2>Banished</h2>
<p>Manage Banished users and nodes</p>
</div>
<RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Blocked </RouterLink>
<RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Banished </RouterLink>
</header>
<div class="glass-card__body">
<p class="text-sm text-gray-600 dark:text-gray-400">
Blocked users and nodes will not be able to send you messages, and their announces will
Banished users and nodes will not be able to send you messages, and their announces will
be ignored.
</p>
</div>
@@ -867,6 +901,7 @@ export default {
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
blackhole_integration_enabled: true,
},
saveTimeouts: {},
shortcuts: [],
@@ -1319,9 +1354,6 @@ export default {
.setting-toggle__hint {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.primary-chip {
@apply inline-flex items-center gap-x-1 rounded-full bg-blue-600/90 px-4 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
}
.info-callout {
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;
}

View File

@@ -138,8 +138,8 @@
<h2 class="text-blue-600 dark:text-blue-400">Generated QR Code</h2>
</div>
<div class="glass-card__body flex flex-col items-center p-4 sm:p-6">
<div class="p-4 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
<div class="size-48 sm:size-64 flex items-center justify-center overflow-hidden">
<div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
<div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
<canvas ref="qrcode"></canvas>
</div>
</div>
@@ -181,11 +181,20 @@
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 rounded-xl font-bold transition-all active:scale-[0.98] text-sm"
@click="downloadQRCode"
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
:disabled="isSending"
@click="sendPaperMessage"
>
<MaterialDesignIcon icon-name="download" class="size-4" />
Save
<template v-if="isSending">
<div
class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
></div>
Sending...
</template>
<template v-else>
<MaterialDesignIcon icon-name="send" class="size-4" />
Send
</template>
</button>
</div>
</div>
@@ -227,6 +236,7 @@ export default {
isGenerating: false,
generatedUri: null,
ingestUri: "",
isSending: false,
};
},
computed: {
@@ -284,7 +294,7 @@ export default {
try {
await QRCode.toCanvas(this.$refs.qrcode, this.generatedUri, {
width: 320,
width: 256,
margin: 2,
color: {
dark: "#000000",
@@ -341,6 +351,54 @@ export default {
link.click();
}
},
async sendPaperMessage() {
const canvas = this.$refs.qrcode;
if (!canvas || !this.destinationHash || !this.generatedUri) return;
try {
this.isSending = true;
// get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
const dataUrl = canvas.toDataURL("image/png");
// extract base64 data by removing the prefix
const imageBytes = dataUrl.split(",")[1];
// build lxmf message
const lxmf_message = {
destination_hash: this.destinationHash,
content: this.generatedUri,
fields: {
image: {
image_type: "png",
image_bytes: imageBytes,
name: "qrcode.png",
},
},
};
// send message
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
delivery_method: "opportunistic",
lxmf_message: lxmf_message,
});
if (response.data.status === "success") {
ToastUtils.success("Paper message sent successfully");
this.generatedUri = null;
this.destinationHash = "";
this.content = "";
this.title = "";
} 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");
} finally {
this.isSending = false;
}
},
printQRCode() {
const canvas = this.$refs.qrcode;
if (!canvas) return;

View File

@@ -0,0 +1,311 @@
<template>
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<!-- header -->
<div
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<MaterialDesignIcon icon-name="route" class="size-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">RNPath</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Reticulum Path Management Utility</p>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button
class="p-2 text-gray-500 hover:text-indigo-500 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors"
title="Refresh"
@click="refreshAll"
>
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
<!-- tabs -->
<div class="flex border-b border-gray-200 dark:border-zinc-800">
<button
v-for="t in ['table', 'rates', 'actions']"
:key="t"
class="px-6 py-3 text-sm font-semibold transition-colors border-b-2 -mb-px"
:class="[
tab === t
? 'text-indigo-600 border-indigo-500 dark:text-indigo-400 dark:border-indigo-400'
: 'text-gray-500 border-transparent hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
]"
@click="tab = t"
>
{{ t.charAt(0).toUpperCase() + t.slice(1) }}
</button>
</div>
<!-- path table -->
<div v-if="tab === 'table'" class="space-y-4">
<div v-if="pathTable.length === 0" class="glass-card p-12 text-center text-gray-500">
No paths currently known.
</div>
<div v-else class="grid gap-4">
<div
v-for="path in pathTable"
:key="path.hash"
class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
>
<div class="min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
{{ path.hash }}
</span>
<span
class="px-2 py-0.5 text-[10px] font-bold bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded uppercase tracking-wider"
>
{{ path.hops }} {{ path.hops === 1 ? "hop" : "hops" }}
</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
via {{ path.via }} on {{ path.interface }}
</div>
<div class="text-[10px] text-gray-400 mt-1">Expires: {{ formatDate(path.expires) }}</div>
</div>
<button
class="px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 rounded-lg transition-colors border border-red-200 dark:border-red-900/30"
@click="dropPath(path.hash)"
>
Drop Path
</button>
</div>
</div>
</div>
<!-- announce rates -->
<div v-if="tab === 'rates'" class="space-y-4">
<div v-if="rateTable.length === 0" class="glass-card p-12 text-center text-gray-500">
No announce rate data available.
</div>
<div v-else class="grid gap-4">
<div v-for="rate in rateTable" :key="rate.hash" class="glass-card p-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
<span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
{{ rate.hash }}
</span>
<span
v-if="rate.blocked_until > Date.now() / 1000"
class="px-2 py-0.5 text-[10px] font-bold bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded"
>
RATE LIMITED
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div class="text-[10px] uppercase text-gray-500">Last Heard</div>
<div class="text-xs font-medium">{{ formatTimeAgo(rate.last) }}</div>
</div>
<div>
<div class="text-[10px] uppercase text-gray-500">Announces</div>
<div class="text-xs font-medium">{{ rate.timestamps.length }}</div>
</div>
<div>
<div class="text-[10px] uppercase text-gray-500">Violations</div>
<div
class="text-xs font-medium"
:class="rate.rate_violations > 0 ? 'text-red-500' : ''"
>
{{ rate.rate_violations }}
</div>
</div>
<div>
<div class="text-[10px] uppercase text-gray-500">Rate</div>
<div class="text-xs font-medium">{{ calculateRate(rate) }} / hr</div>
</div>
</div>
</div>
</div>
</div>
<!-- manual actions -->
<div v-if="tab === 'actions'" class="max-w-2xl mx-auto space-y-6">
<!-- request path -->
<section class="glass-card p-6 space-y-4">
<h2 class="text-lg font-bold">Request Path</h2>
<p class="text-sm text-gray-500">Broadcast a path request for a destination hash.</p>
<div class="flex gap-2">
<input
v-model="requestHash"
type="text"
placeholder="Destination Hash (32 hex chars)"
class="input-field flex-1 font-mono"
/>
<button
class="px-4 py-2 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-500 transition shadow-lg shadow-indigo-500/20 active:scale-95 disabled:opacity-50"
:disabled="requestHash.length !== 32"
@click="requestPath"
>
Request
</button>
</div>
</section>
<!-- drop all via -->
<section class="glass-card p-6 space-y-4">
<h2 class="text-lg font-bold">Drop All Via</h2>
<p class="text-sm text-gray-500">
Remove all known paths routed through a specific transport instance.
</p>
<div class="flex gap-2">
<input
v-model="dropViaHash"
type="text"
placeholder="Transport Instance Hash"
class="input-field flex-1 font-mono"
/>
<button
class="px-4 py-2 bg-red-600 text-white rounded-xl font-semibold hover:bg-red-500 transition shadow-lg shadow-red-500/20 active:scale-95 disabled:opacity-50"
:disabled="dropViaHash.length !== 32"
@click="dropAllVia"
>
Drop All
</button>
</div>
</section>
<!-- drop queues -->
<section class="glass-card p-6 space-y-4">
<h2 class="text-lg font-bold">Drop Announce Queues</h2>
<p class="text-sm text-gray-500">
Clear all outbound announce packets currently queued on all interfaces.
</p>
<button
class="w-full px-4 py-3 bg-zinc-800 text-white rounded-xl font-semibold hover:bg-zinc-700 transition active:scale-95"
@click="dropAnnounceQueues"
>
Purge All Queues
</button>
</section>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
export default {
name: "RNPathPage",
components: {
MaterialDesignIcon,
},
data() {
return {
tab: "table",
isLoading: false,
pathTable: [],
rateTable: [],
requestHash: "",
dropViaHash: "",
};
},
mounted() {
this.refreshAll();
},
methods: {
async refreshAll() {
this.isLoading = true;
try {
const [pathRes, rateRes] = await Promise.all([
window.axios.get("/api/v1/rnpath/table"),
window.axios.get("/api/v1/rnpath/rates"),
]);
this.pathTable = pathRes.data.table;
this.rateTable = rateRes.data.rates;
} catch (e) {
console.error(e);
ToastUtils.error("Failed to fetch path data");
} finally {
this.isLoading = false;
}
},
async dropPath(hash) {
if (!(await DialogUtils.confirm(`Are you sure you want to drop the path to ${hash}?`))) {
return;
}
try {
const res = await window.axios.post("/api/v1/rnpath/drop", { destination_hash: hash });
if (res.data.success) {
ToastUtils.success("Path dropped");
this.refreshAll();
} else {
ToastUtils.error("Could not drop path");
}
} catch {
ToastUtils.error("Error dropping path");
}
},
async requestPath() {
try {
await window.axios.post("/api/v1/rnpath/request", { destination_hash: this.requestHash });
ToastUtils.success(`Path requested for ${this.requestHash.substring(0, 8)}...`);
this.requestHash = "";
// Path requests take time, don't refresh immediately
} catch {
ToastUtils.error("Failed to request path");
}
},
async dropAllVia() {
if (!(await DialogUtils.confirm(`Drop ALL paths via ${this.dropViaHash}?`))) {
return;
}
try {
const res = await window.axios.post("/api/v1/rnpath/drop-via", {
transport_instance_hash: this.dropViaHash,
});
if (res.data.success) {
ToastUtils.success("Paths dropped");
this.dropViaHash = "";
this.refreshAll();
}
} catch {
ToastUtils.error("Failed to drop paths");
}
},
async dropAnnounceQueues() {
if (!(await DialogUtils.confirm("Purge all announce queues? This cannot be undone."))) {
return;
}
try {
await window.axios.post("/api/v1/rnpath/drop-queues");
ToastUtils.success("Announce queues purged");
} catch {
ToastUtils.error("Failed to purge queues");
}
},
calculateRate(rate) {
if (rate.timestamps.length === 0) return 0;
const startTs = rate.timestamps[0];
const span = Math.max(Date.now() / 1000 - startTs, 3600.0);
const spanHours = span / 3600.0;
return (rate.timestamps.length / spanHours).toFixed(2);
},
formatDate(ts) {
return new Date(ts * 1000).toLocaleString();
},
formatTimeAgo(ts) {
return Utils.formatSecondsAgo(Math.floor(Date.now() / 1000 - ts));
},
},
};
</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-2xl shadow-lg;
}
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 dark:focus:ring-indigo-500 dark:focus:border-indigo-500 block w-full p-3 text-gray-900 dark:text-gray-100 transition;
}
</style>

View File

@@ -70,6 +70,21 @@
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
>
<MaterialDesignIcon icon-name="route" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnpath.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"