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

293 lines
14 KiB
Vue

<template>
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<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-red-100 dark:bg-red-900/30 rounded-lg">
<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">{{ $t("banishment.title") }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("banishment.description") }}</p>
</div>
</div>
<div class="ml-auto flex items-center gap-2 sm:gap-4">
<div class="relative w-32 sm:w-64 md:w-80">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
<input
v-model="searchQuery"
type="text"
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
:placeholder="$t('banishment.search_placeholder')"
@input="onSearchInput"
/>
</div>
<button
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
:title="$t('common.refresh')"
@click="loadBlockedDestinations"
>
<MaterialDesignIcon
icon-name="refresh"
class="size-6"
:class="{ 'animate-spin-reverse': isLoading }"
/>
</button>
</div>
</div>
<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">{{ $t("banishment.loading_items") }}</p>
</div>
<div
v-else-if="filteredBlockedItems.length === 0"
class="flex flex-col items-center justify-center h-64 text-center"
>
<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">{{ $t("banishment.no_items") }}</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{{ searchQuery ? $t("nomadnet.no_search_results_peers") : $t("nomadnet.no_announces_yet") }}
</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in filteredBlockedItems"
:key="item.destination_hash"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
>
<div class="p-5">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg flex-shrink-0">
<MaterialDesignIcon
icon-name="account-off"
class="size-5 text-red-600 dark:text-red-400"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<h4
class="text-base font-semibold text-gray-900 dark:text-white break-words"
:title="item.display_name"
>
{{ item.display_name || $t("call.unknown") }}
</h4>
<span
v-if="item.is_node"
class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
>
{{ $t("banishment.node") }}
</span>
<span
v-else
class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded"
>
{{ $t("banishment.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"
:title="item.destination_hash"
>
{{ item.destination_hash }}
</p>
</div>
</div>
<div v-if="item.created_at" class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{{ $t("banishment.banished_at") }} {{ 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>
<button
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors font-medium"
@click="onUnblock(item)"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
<span>{{ $t("banishment.lift_banishment") }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import Utils from "../../js/Utils";
export default {
name: "BlockedPage",
components: {
MaterialDesignIcon,
},
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.allBlockedItems;
}
const query = this.searchQuery.toLowerCase();
return this.allBlockedItems.filter((item) => {
const matchesHash = item.destination_hash.toLowerCase().includes(query);
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
return matchesHash || matchesDisplayName;
});
},
},
mounted() {
this.loadBlockedDestinations();
},
methods: {
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 || [];
// 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);
}
const processItem = async (hash, data = {}) => {
let displayName = this.$t("call.unknown");
let isNode = false;
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 || this.$t("call.unknown");
isNode = announce.aspect === "nomadnetwork.node";
}
} catch {
// ignore error
}
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(this.$t("banishment.failed_load_banished"));
} finally {
this.isLoading = false;
}
},
async onUnblock(item) {
if (
!(await DialogUtils.confirm(
this.$t("banishment.lift_banishment_confirm", { name: item.display_name || item.destination_hash })
))
) {
return;
}
try {
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
await this.loadBlockedDestinations();
ToastUtils.success(this.$t("banishment.banishment_lifted"));
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("banishment.failed_lift_banishment"));
}
},
onSearchInput() {},
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
},
};
</script>