feat(frontend) update styling

This commit is contained in:
Ivan
2026-05-11 18:13:56 -05:00
parent b2e8e3303b
commit 0e86b574ed
7 changed files with 454 additions and 364 deletions
@@ -96,18 +96,18 @@
<div class="mt-6 pt-6 border-t border-gray-200/70 dark:border-zinc-800/80">
<button
type="button"
class="group w-full flex items-center justify-between gap-3 px-4 py-3 rounded-2xl bg-gradient-to-br from-blue-500/[0.08] via-slate-500/[0.06] to-violet-500/[0.08] dark:from-blue-500/15 dark:via-zinc-800/40 dark:to-violet-500/15 hover:from-blue-500/15 hover:to-violet-500/15 dark:hover:from-blue-500/25 dark:hover:to-violet-500/25 border border-gray-200/80 dark:border-zinc-700/80 transition-all text-left min-h-[52px]"
class="about-action-btn secondary-chip w-full justify-between text-left"
:aria-expanded="showContactSupport"
@click="showContactSupport = !showContactSupport"
>
<div class="flex items-center gap-3 min-w-0">
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-500/15 dark:bg-blue-500/25 text-blue-600 dark:text-blue-300 ring-1 ring-blue-500/20"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300"
>
<v-icon icon="mdi-card-account-details-outline" size="22"></v-icon>
</span>
<span
class="text-xs font-black uppercase tracking-widest text-gray-800 dark:text-zinc-100 truncate"
class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-zinc-100 truncate"
>
{{ $t("about.contact_support_title") }}
</span>
@@ -120,129 +120,105 @@
</button>
<transition name="fade">
<div
v-if="showContactSupport"
class="mt-4 p-5 sm:p-6 rounded-2xl bg-white/70 dark:bg-zinc-950/70 border border-gray-200/90 dark:border-zinc-800 shadow-sm space-y-6"
>
<div class="space-y-4">
<div v-if="showContactSupport" class="mt-6 flex flex-col gap-6">
<div class="flex flex-col gap-3">
<div
class="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-500 dark:text-zinc-400"
class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide flex items-center gap-2"
>
<v-icon
icon="mdi-account-circle-outline"
size="18"
class="text-blue-500 dark:text-blue-400"
></v-icon>
<v-icon icon="mdi-account-circle-outline" size="16"></v-icon>
{{ $t("about.contact_developer") }}
</div>
<div class="space-y-3">
<div>
<div
class="text-[9px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
<div class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-gray-50 dark:bg-zinc-900/40 border border-gray-100 dark:border-zinc-800"
>
<router-link
:to="{
name: 'messages',
params: { destinationHash: developerLxmfPrimary },
}"
class="flex-1 min-w-0 text-sm font-mono text-gray-700 dark:text-zinc-300 hover:text-blue-600 dark:hover:text-blue-400 break-all leading-snug text-left"
:title="$t('about.contact_open_messages')"
>
{{ $t("about.contact_lxmf_address") }}
</div>
<div class="flex items-start gap-2">
<router-link
:to="{
name: 'messages',
params: { destinationHash: developerLxmfPrimary },
}"
class="flex-1 min-w-0 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline underline-offset-2 break-all leading-snug text-left"
:title="$t('about.contact_open_messages')"
>
{{ developerLxmfPrimary }}
</router-link>
<button
type="button"
class="shrink-0 rounded-lg p-2 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
:aria-label="$t('about.contact_copy_address')"
@click="
copyValue(
developerLxmfPrimary,
'about.contact_lxmf_address'
)
"
>
<v-icon icon="mdi-content-copy" size="18"></v-icon>
</button>
</div>
{{ developerLxmfPrimary }}
</router-link>
<button
type="button"
class="shrink-0 rounded-lg p-1.5 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
:aria-label="$t('about.contact_copy_address')"
@click="
copyValue(developerLxmfPrimary, 'about.contact_lxmf_address')
"
>
<v-icon icon="mdi-content-copy" size="16"></v-icon>
</button>
</div>
<div>
<div
class="text-[9px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
<div
class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-gray-50 dark:bg-zinc-900/40 border border-gray-100 dark:border-zinc-800"
>
<router-link
:to="{
name: 'messages',
params: { destinationHash: developerLxmfAlternate },
}"
class="flex-1 min-w-0 text-sm font-mono text-gray-700 dark:text-zinc-300 hover:text-blue-600 dark:hover:text-blue-400 break-all leading-snug text-left"
:title="$t('about.contact_open_messages')"
>
{{ $t("about.contact_alternate") }}
</div>
<div class="flex items-start gap-2">
<router-link
:to="{
name: 'messages',
params: { destinationHash: developerLxmfAlternate },
}"
class="flex-1 min-w-0 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline underline-offset-2 break-all leading-snug text-left"
:title="$t('about.contact_open_messages')"
>
{{ developerLxmfAlternate }}
</router-link>
<button
type="button"
class="shrink-0 rounded-lg p-2 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
:aria-label="$t('about.contact_copy_address')"
@click="
copyValue(developerLxmfAlternate, 'about.contact_alternate')
"
>
<v-icon icon="mdi-content-copy" size="18"></v-icon>
</button>
</div>
{{ developerLxmfAlternate }}
</router-link>
<button
type="button"
class="shrink-0 rounded-lg p-1.5 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
:aria-label="$t('about.contact_copy_address')"
@click="
copyValue(developerLxmfAlternate, 'about.contact_alternate')
"
>
<v-icon icon="mdi-content-copy" size="16"></v-icon>
</button>
</div>
</div>
<div
class="text-xs font-semibold text-gray-600 dark:text-zinc-400 bg-gray-50 dark:bg-zinc-900/50 p-3 rounded-xl border border-gray-200/90 dark:border-zinc-800 flex items-start gap-2.5"
class="text-xs text-gray-500 dark:text-zinc-400 bg-gray-50 dark:bg-zinc-900/40 p-3 rounded-xl border border-gray-100 dark:border-zinc-800 flex items-start gap-2"
>
<v-icon
icon="mdi-information-outline"
size="18"
class="text-blue-500 dark:text-blue-400 shrink-0 mt-0.5"
size="16"
class="shrink-0 mt-0.5"
></v-icon>
<span>{{ $t("about.contact_propagation_hint") }}</span>
</div>
</div>
<div class="border-t border-gray-100 dark:border-zinc-800/90 pt-2"></div>
<div class="border-t border-gray-100 dark:border-zinc-800/90"></div>
<div class="space-y-4">
<div class="flex flex-col gap-3">
<div
class="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-500 dark:text-zinc-400"
class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide flex items-center gap-2"
>
<v-icon
icon="mdi-hand-heart"
size="18"
class="text-blue-500 dark:text-blue-400"
></v-icon>
<v-icon icon="mdi-hand-heart" size="16"></v-icon>
{{ $t("about.donate_label") }}
</div>
<div>
<div
class="text-[9px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide mb-1.5"
>
{{ $t("about.donate_monero_label") }}
</div>
<div class="flex items-start gap-2">
<div
class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-gray-50 dark:bg-zinc-900/40 border border-gray-100 dark:border-zinc-800"
>
<span
class="flex-1 min-w-0 text-sm font-medium text-gray-800 dark:text-zinc-200 break-all leading-snug select-all"
class="flex-1 min-w-0 text-sm font-mono text-gray-700 dark:text-zinc-300 break-all leading-snug select-all"
>{{ moneroDonateAddress }}</span
>
<button
type="button"
class="shrink-0 rounded-lg p-2 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
class="shrink-0 rounded-lg p-1.5 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
:aria-label="$t('about.donate_copy_monero')"
@click="copyValue(moneroDonateAddress, 'about.donate_monero_label')"
>
<v-icon icon="mdi-content-copy" size="18"></v-icon>
<v-icon icon="mdi-content-copy" size="16"></v-icon>
</button>
</div>
</div>
@@ -252,12 +228,12 @@
href="https://ko-fi.com/quad4"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/70 hover:bg-blue-500/[0.06] hover:border-blue-500/25 dark:hover:bg-zinc-800/80 text-gray-800 dark:text-zinc-100 text-xs font-bold uppercase tracking-wide min-h-[44px] transition-colors"
class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900/40 hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-800 dark:text-zinc-100 text-xs font-semibold transition-colors"
>
<v-icon
icon="mdi-coffee"
size="18"
class="text-blue-600 dark:text-blue-400"
class="text-gray-500 dark:text-zinc-400"
></v-icon>
{{ $t("about.donate_kofi") }}
</a>
@@ -265,12 +241,12 @@
href="https://buymeacoffee.com/quad4"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/70 hover:bg-blue-500/[0.06] hover:border-blue-500/25 dark:hover:bg-zinc-800/80 text-gray-800 dark:text-zinc-100 text-xs font-bold uppercase tracking-wide min-h-[44px] transition-colors"
class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900/40 hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-800 dark:text-zinc-100 text-xs font-semibold transition-colors"
>
<v-icon
icon="mdi-cup"
size="18"
class="text-blue-600 dark:text-blue-400"
class="text-gray-500 dark:text-zinc-400"
></v-icon>
{{ $t("about.donate_buymeacoffee") }}
</a>
@@ -987,6 +963,19 @@
</div>
</div>
<div
v-if="autoBackups.some((b) => b.name.includes('SUSPICIOUS'))"
class="mt-4 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2"
>
<v-icon icon="mdi-alert" size="16" class="shrink-0 mt-0.5"></v-icon>
<span
>Suspicious backups are created when the database size or message count
drops unexpectedly compared to the last known baseline, usually after a
crash, corruption, or deletion. They are kept automatically so you can
inspect or restore from them.</span
>
</div>
<!-- Backups Pagination -->
<div
v-if="autoBackupsTotal > autoBackupsLimit"
@@ -1072,7 +1061,7 @@ export default {
autoBackups: [],
autoBackupsTotal: 0,
autoBackupsOffset: 0,
autoBackupsLimit: 3,
autoBackupsLimit: 4,
electronVersion: null,
chromeVersion: null,
nodeVersion: null,
@@ -1,167 +1,185 @@
<!-- SPDX-License-Identifier: 0BSD -->
<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-xs"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<MaterialDesignIcon icon-name="gavel" 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-hidden 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 && filteredBlockedIdentities.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="filteredBlockedIdentities.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
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-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 overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4 min-w-0">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl mx-auto min-w-0">
<div
v-for="identity in filteredBlockedIdentities"
:key="identity.identity_hash"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"
>
<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 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 flex-wrap">
<h4
class="text-base font-semibold text-gray-900 dark:text-white wrap-break-word"
:title="identity.display_name"
>
{{ identity.display_name || $t("call.unknown") }}
</h4>
<span
v-if="identity.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-sm"
>
{{ $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-sm"
>
{{ $t("banishment.user") }}
</span>
<span
v-if="identity.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-sm 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="identity.identity_hash"
>
{{ identity.identity_hash }}
</p>
</div>
</div>
<!-- Blocked destination hashes -->
<div v-if="identity.blocked_destinations.length > 0" class="mb-2">
<p class="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{{ $t("banishment.blocked_destinations") }}
</p>
<div class="space-y-1">
<div
v-for="dest in identity.blocked_destinations"
:key="dest.destination_hash"
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-zinc-800 px-2 py-1 rounded"
>
<span class="break-all">{{ dest.destination_hash }}</span>
<span
v-if="dest.created_at"
class="shrink-0 ml-2 text-gray-400 dark:text-zinc-500"
>
{{ formatTimeAgo(dest.created_at) }}
</span>
</div>
</div>
</div>
<div
v-if="identity.rns_reason"
class="text-xs italic text-zinc-500 dark:text-zinc-400 mb-2"
>
"{{ identity.rns_reason }}"
</div>
<div
v-if="identity.rns_source"
class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
>
Source: {{ identity.rns_source }}
</div>
</div>
<div class="min-w-0 space-y-1">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
{{ $t("banishment.title") }}
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t("banishment.description") }}
</p>
</div>
<div class="flex items-center gap-2 sm:shrink-0">
<div class="relative flex-1 sm:w-64 lg:w-80">
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-3 top-1/2 -translate-y-1/2 size-5 shrink-0 text-gray-400 pointer-events-none z-10"
/>
<input
v-model="searchQuery"
type="text"
class="input-field pl-11!"
:placeholder="$t('banishment.search_placeholder')"
@input="onSearchInput"
/>
</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(identity)"
type="button"
class="secondary-chip p-2.5!"
:title="$t('common.refresh')"
@click="loadBlockedDestinations"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
<span>{{ $t("banishment.lift_banishment") }}</span>
<MaterialDesignIcon
icon-name="refresh"
class="size-5"
:class="{ 'animate-spin-reverse': isLoading }"
/>
</button>
</div>
</div>
<template v-if="isLoading && filteredBlockedIdentities.length === 0">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4 sm:py-6">
<div
v-for="i in 5"
:key="'skel-' + i"
class="banishment-card overflow-hidden p-5 space-y-4 min-w-0"
>
<div class="flex items-start gap-3">
<div class="size-10 rounded-xl bg-gray-200 dark:bg-zinc-700 animate-pulse shrink-0" />
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 w-28 bg-gray-200 dark:bg-zinc-700 rounded-sm animate-pulse" />
<div class="h-3 w-44 bg-gray-100 dark:bg-zinc-800 rounded-sm animate-pulse" />
</div>
</div>
<div class="space-y-1.5">
<div class="h-3 w-20 bg-gray-100 dark:bg-zinc-800 rounded-sm animate-pulse" />
<div class="h-8 bg-gray-100 dark:bg-zinc-800 rounded-lg animate-pulse" />
</div>
<div class="h-9 bg-gray-100 dark:bg-zinc-800 rounded-xl animate-pulse" />
</div>
</div>
</template>
<div
v-else-if="filteredBlockedIdentities.length === 0"
class="flex flex-col items-center justify-center py-16 sm:py-20 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-semibold text-gray-900 dark:text-white">
{{ $t("banishment.no_items") }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto mt-1">
{{ searchQuery ? $t("nomadnet.no_search_results_peers") : $t("nomadnet.no_announces_yet") }}
</p>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4 sm:py-6">
<div
v-for="identity in filteredBlockedIdentities"
:key="identity.identity_hash"
class="banishment-card overflow-hidden group min-w-0"
>
<div class="p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-xl shrink-0">
<MaterialDesignIcon
icon-name="account-off"
class="size-5 text-red-600 dark:text-red-400"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-1.5 mb-1">
<h3
class="text-sm font-bold text-gray-900 dark:text-white truncate max-w-full"
:title="identity.display_name || $t('call.unknown')"
>
{{ identity.display_name || $t("call.unknown") }}
</h3>
<span
v-if="identity.is_node"
class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-[10px] font-bold uppercase tracking-wider shrink-0"
>
{{ $t("banishment.node") }}
</span>
<span
v-else
class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 text-[10px] font-bold uppercase tracking-wider shrink-0"
>
{{ $t("banishment.user") }}
</span>
<span
v-if="identity.is_rns_blackholed"
class="px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[10px] font-bold uppercase tracking-wider border border-zinc-200 dark:border-zinc-700 shrink-0"
title="Blackholed at Reticulum transport layer"
>
RNS Blackhole
</span>
</div>
<p
class="text-xs font-mono text-gray-500 dark:text-gray-400 truncate"
:title="identity.identity_hash"
>
{{ identity.identity_hash }}
</p>
</div>
</div>
<div v-if="identity.blocked_destinations.length > 0">
<p
class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2 uppercase tracking-wide"
>
{{ $t("banishment.blocked_destinations") }}
</p>
<div class="space-y-1">
<div
v-for="dest in identity.blocked_destinations"
:key="dest.destination_hash"
class="flex items-center justify-between gap-2 px-3 py-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg text-xs"
>
<span
class="font-mono text-gray-500 dark:text-gray-400 truncate min-w-0"
:title="dest.destination_hash"
>
{{ dest.destination_hash }}
</span>
<span v-if="dest.created_at" class="shrink-0 text-gray-400 dark:text-zinc-500">
{{ formatTimeAgo(dest.created_at) }}
</span>
</div>
</div>
</div>
<div
v-if="identity.rns_reason"
class="text-xs italic text-zinc-500 dark:text-zinc-400 leading-relaxed"
>
&ldquo;{{ identity.rns_reason }}&rdquo;
</div>
<div
v-if="identity.rns_source"
class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate"
>
Source: {{ identity.rns_source }}
</div>
<button class="primary-chip w-full justify-center" @click="onUnblock(identity)">
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
<span>{{ $t("banishment.lift_banishment") }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -212,11 +230,9 @@ export default {
async loadBlockedDestinations() {
this.isLoading = true;
try {
// Load local blocked destinations
const response = await window.api.get("/api/v1/blocked-destinations");
const blockedHashes = response.data.blocked_destinations || [];
// Load Reticulum blackholed identities
let reticulumBlackholed = {};
try {
const rnsResponse = await window.api.get("/api/v1/reticulum/blackhole");
@@ -243,7 +259,6 @@ export default {
return identityMap[identityHash];
};
// Process local blocked destinations
const processBlockedHash = async (blocked) => {
const hash = blocked.destination_hash;
let identityHash = hash;
@@ -280,7 +295,6 @@ export default {
await Promise.all(blockedHashes.map((blocked) => processBlockedHash(blocked)));
// Process Reticulum blackholed identities
for (const [hash, info] of Object.entries(reticulumBlackholed)) {
const identity = ensureIdentity(hash);
identity.is_rns_blackholed = true;
@@ -288,7 +302,6 @@ export default {
identity.rns_reason = info.reason || null;
identity.rns_until = info.until || null;
// Try to look up display name from announces
if (!identity.display_name) {
try {
const announceResponse = await window.api.get("/api/v1/announces", {
@@ -329,7 +342,6 @@ export default {
}
try {
// Use the first blocked destination hash, or fall back to identity hash
const targetHash =
identity.blocked_destinations.length > 0
? identity.blocked_destinations[0].destination_hash
@@ -350,3 +362,16 @@ export default {
},
};
</script>
<style scoped>
@reference "../../style.css";
.banishment-card {
@apply bg-white dark:bg-zinc-900/95 border border-gray-200/70 dark:border-zinc-800/80 rounded-2xl shadow-sm transition-all duration-200;
}
.banishment-card:hover {
@apply shadow-md border-gray-300/80 dark:border-zinc-700/80;
}
.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-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-3 text-gray-900 dark:text-gray-100 transition;
}
</style>
@@ -84,7 +84,7 @@
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-zinc-800">
<div
v-for="contact in contacts"
v-for="contact in mergedContacts"
:key="contact.id"
class="group flex cursor-default items-center gap-3 px-1 py-3 transition-colors hover:bg-gray-50/80 dark:hover:bg-zinc-900/70"
@contextmenu.prevent="openContextMenu($event, contact)"
@@ -106,8 +106,30 @@
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">
{{ contact.name }}
</div>
<div class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">
{{ contact.lxmf_address || contact.remote_identity_hash }}
<div class="flex flex-col gap-0.5">
<div v-if="contact.remote_destination_hash" class="flex items-center gap-1.5">
<MaterialDesignIcon
icon-name="message-text-outline"
class="size-4 text-blue-500 dark:text-blue-400 shrink-0"
/>
<span class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">{{
contact.remote_destination_hash
}}</span>
</div>
<div v-if="contact.remote_telephony_hash" class="flex items-center gap-1.5">
<MaterialDesignIcon
icon-name="phone-outline"
class="size-4 text-green-600 dark:text-green-400 shrink-0"
/>
<span class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">{{
contact.remote_telephony_hash
}}</span>
</div>
<span
v-if="!contact.remote_destination_hash && !contact.remote_telephony_hash"
class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all"
>{{ contact.lxmf_address || contact.remote_identity_hash }}</span
>
</div>
</div>
<div
@@ -475,6 +497,24 @@ export default {
hasMoreContacts() {
return this.contacts.length < this.totalContactsCount;
},
mergedContacts() {
const map = new Map();
for (const c of this.contacts) {
const key = c.name?.toLowerCase() || "";
if (!map.has(key)) {
map.set(key, { ...c });
} else {
const existing = map.get(key);
// Merge fields so both LXMF and LXST addresses are visible
existing.lxmf_address = existing.lxmf_address || c.lxmf_address;
existing.lxst_address = existing.lxst_address || c.lxst_address;
existing.remote_destination_hash = existing.remote_destination_hash || c.remote_destination_hash;
existing.remote_telephony_hash = existing.remote_telephony_hash || c.remote_telephony_hash;
existing.remote_identity_hash = existing.remote_identity_hash || c.remote_identity_hash;
}
}
return Array.from(map.values());
},
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -669,7 +709,6 @@ export default {
await window.api.post("/api/v1/telephone/contacts", {
name: this.newContactName?.trim() || `Contact ${destinationHash.slice(0, 8)}`,
remote_identity_hash: destinationHash,
lxmf_address: destinationHash,
});
ToastUtils.success(this.$t("contacts.contact_added"));
@@ -704,9 +743,17 @@ export default {
async removeContact(contact) {
this.closeContextMenu();
if (!contact?.id) return;
if (!window.confirm(this.$t("contacts.remove_contact_confirm"))) return;
const duplicates = this.contacts.filter((c) => c.name === contact.name && c.id !== contact.id);
const confirmMsg =
duplicates.length > 0
? `${this.$t("contacts.remove_contact_confirm")}\n\n(${duplicates.length} additional duplicate${duplicates.length > 1 ? "s" : ""} named "${contact.name}" will also be removed)`
: this.$t("contacts.remove_contact_confirm");
if (!window.confirm(confirmMsg)) return;
try {
await window.api.delete(`/api/v1/telephone/contacts/${contact.id}`);
const ids = [contact.id, ...duplicates.map((c) => c.id)];
for (const id of ids) {
await window.api.delete(`/api/v1/telephone/contacts/${id}`);
}
ToastUtils.success(this.$t("contacts.contact_removed"));
await this.getContacts();
} catch {
@@ -719,13 +766,19 @@ export default {
const name = await DialogUtils.prompt(this.$t("contacts.enter_contact_name"), contact.name);
if (name == null || name === contact.name) return;
try {
await window.api.patch(`/api/v1/telephone/contacts/${contact.id}`, { name });
const destHash =
contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash;
if (destHash && name.length > 0) {
await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, {
display_name: name,
});
const duplicates = this.contacts.filter((c) => c.name === contact.name && c.id !== contact.id);
const ids = [contact.id, ...duplicates.map((c) => c.id)];
for (const id of ids) {
await window.api.patch(`/api/v1/telephone/contacts/${id}`, { name });
}
const allContacts = [contact, ...duplicates];
for (const c of allContacts) {
const destHash = c.remote_destination_hash || c.lxmf_address || c.remote_identity_hash;
if (destHash && name.length > 0) {
await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, {
display_name: name,
});
}
}
ToastUtils.success(this.$t("contacts.contact_updated"));
await this.getContacts();
@@ -84,7 +84,8 @@
class="text-blue-500 hover:underline"
@click="onIFACSignatureClick(iface._stats.ifac_signature)"
>
{{ iface._stats.ifac_signature.slice(0, 8) }}{{ iface._stats.ifac_signature.slice(-8) }}
<span class="font-mono">{{ iface._stats.ifac_signature.slice(0, 8) }}</span
><span class="font-mono">{{ iface._stats.ifac_signature.slice(-8) }}</span>
</button>
</div>
</div>
@@ -258,6 +259,9 @@ export default {
if (this.iface.type === "AutoInterface") {
return "Auto-detect Ethernet and Wi-Fi peers";
}
if (this.iface.type === "BackboneInterface") {
return "Backbone (IFAC tunnel)";
}
return this.iface.description || "Custom interface";
},
statusChipClass() {
@@ -80,7 +80,7 @@
@click="refreshCommunityInterfaces"
>
<MaterialDesignIcon
icon-name="refresh"
icon-name="download"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': refreshingCommunityInterfaces }"
/>
@@ -1,39 +1,45 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div
class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
>
<div class="space-y-4 w-full max-w-5xl mx-auto">
<div class="glass-card space-y-5">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Reticulum Content Serving
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Mesh Server</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Serve Micron pages and files directly over the Reticulum mesh. Each server gets its own
identity and destination address.
</div>
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-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 overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4 min-w-0">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl mx-auto min-w-0">
<div
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 flex flex-col lg:flex-row lg: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">
Reticulum Content Serving
</div>
<button
type="button"
class="primary-chip px-4 py-2 text-sm shrink-0"
@click="showCreateDialog = true"
>
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
Create Node
</button>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">Mesh Server</h1>
<p class="text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
Serve Micron pages and files directly over the Reticulum mesh. Each server gets its own
identity and destination address.
</p>
</div>
<button
type="button"
class="primary-chip px-4 py-2 text-sm shrink-0"
@click="showCreateDialog = true"
>
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
Create Node
</button>
</div>
<div v-if="loading" class="glass-card text-center py-12">
<div
v-if="loading"
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-8 sm:py-12 text-center"
>
<div class="text-gray-500 dark:text-gray-400">Loading nodes...</div>
</div>
<div v-else-if="nodes.length === 0" class="glass-card text-center py-12">
<div
v-else-if="nodes.length === 0"
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-8 sm:py-12 text-center"
>
<MaterialDesignIcon icon-name="server-network" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<div class="text-gray-600 dark:text-gray-400 mb-2">No mesh servers yet</div>
<div class="text-sm text-gray-500 dark:text-gray-500">
@@ -41,64 +47,66 @@
</div>
</div>
<div v-else class="space-y-3">
<div v-else class="w-full divide-y divide-gray-200/60 dark:divide-zinc-800/60">
<div
v-for="node in nodes"
:key="node.node_id"
class="glass-card space-y-4 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition"
class="py-3 sm:py-4 space-y-2 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors rounded-lg -mx-3 sm:-mx-4 px-3 sm:px-4"
@click="selectNode(node)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div
class="w-3 h-3 rounded-full"
class="w-3 h-3 rounded-full shrink-0"
:class="node.running ? 'bg-green-500' : 'bg-gray-400'"
></div>
<div>
<div class="font-semibold text-gray-900 dark:text-white">{{ node.name }}</div>
<div class="min-w-0">
<div class="font-semibold text-gray-900 dark:text-white truncate">
{{ node.name }}
</div>
<div
v-if="node.destination_hash"
class="text-xs font-mono text-gray-500 dark:text-gray-400"
class="text-xs font-mono text-gray-500 dark:text-gray-400 truncate"
>
{{ node.destination_hash }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ node.pages.length }} pages, {{ node.files.length }} files
<div class="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
<span class="text-xs text-gray-500 dark:text-gray-400 mr-1">
{{ node.pages.length }}p / {{ node.files.length }}f
</span>
<button
v-if="!node.running"
class="primary-chip py-1! px-3! text-xs!"
class="primary-chip py-1! px-2.5! text-xs!"
@click.stop="startNode(node.node_id)"
>
Start
</button>
<button
v-else
class="secondary-chip py-1! px-3! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
class="secondary-chip py-1! px-2.5! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
@click.stop="stopNode(node.node_id)"
>
Stop
</button>
<button
v-if="node.running"
class="secondary-chip py-1! px-3! text-xs!"
class="secondary-chip py-1! px-2.5! text-xs!"
@click.stop="announceNode(node.node_id)"
>
Announce
</button>
<button
v-if="node.running && node.destination_hash"
class="secondary-chip py-1! px-3! text-xs!"
class="secondary-chip py-1! px-2.5! text-xs!"
@click.stop="viewNode(node)"
>
<MaterialDesignIcon icon-name="eye" class="w-3.5 h-3.5" />
View
</button>
<button
class="secondary-chip py-1! px-3! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
class="secondary-chip py-1! px-2.5! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
@click.stop="deleteNode(node.node_id)"
>
<MaterialDesignIcon icon-name="delete" class="w-3.5 h-3.5" />
@@ -108,19 +116,22 @@
<div
v-if="node.stats || node.running"
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400"
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400 pl-6"
>
<span v-if="node.running">{{ formatMeshUptime(node.uptime_seconds) }} uptime</span>
<span>{{ node.unique_connections ?? 0 }} unique connections</span>
<span v-if="node.stats">{{ node.stats.pages_served }} pages served</span>
<span v-if="node.stats">{{ node.stats.files_served }} files served</span>
<span>{{ node.unique_connections ?? 0 }} connections</span>
<span v-if="node.stats">{{ node.stats.pages_served }} pages</span>
<span v-if="node.stats">{{ node.stats.files_served }} files</span>
<span v-if="node.stats">{{ node.stats.links_established }} links</span>
</div>
</div>
</div>
<!-- Selected Node Detail -->
<div v-if="selectedNode" class="glass-card space-y-5">
<div
v-if="selectedNode"
class="w-full py-4 sm:py-6 space-y-4 border-t border-gray-200/60 dark:border-zinc-800/60"
>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ selectedNode.name }}
@@ -154,13 +165,13 @@
</div>
<!-- Tabs: Pages / Files -->
<div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
<div class="flex gap-2 border-b border-gray-200/60 dark:border-zinc-800/60">
<button
:class="[
detailTab === 'pages'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
'px-4 py-2 font-semibold transition text-sm',
'px-4 py-2 font-semibold transition text-sm -mb-px',
]"
@click="detailTab = 'pages'"
>
@@ -171,7 +182,7 @@
detailTab === 'files'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
'px-4 py-2 font-semibold transition text-sm',
'px-4 py-2 font-semibold transition text-sm -mb-px',
]"
@click="detailTab = 'files'"
>
@@ -1,11 +1,13 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
<div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-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 overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4 min-w-0">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl mx-auto min-w-0">
<div
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 flex flex-wrap items-start justify-between gap-3"
>
<div class="flex items-start gap-3 min-w-0">
<div
@@ -13,14 +15,14 @@
>
<MaterialDesignIcon icon-name="package-variant" class="size-6" />
</div>
<div class="min-w-0">
<div class="min-w-0 space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
{{ $t("tools.repository_server.title") }}
</h1>
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 max-w-2xl">
{{ $t("tools.repository_server.subtitle") }}
</p>
</div>
@@ -28,7 +30,7 @@
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="inline-flex items-center justify-center p-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-gray-600 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
class="secondary-chip p-2!"
:title="$t('tools.repository_server.refresh_bundled_tooltip')"
:disabled="refreshing"
@click="refreshBundled"
@@ -37,7 +39,7 @@
</button>
<RouterLink
to="/tools"
class="inline-flex items-center gap-2 text-sm text-sky-600 dark:text-sky-300 hover:underline"
class="inline-flex items-center gap-1 text-sm text-sky-600 dark:text-sky-300 hover:underline"
>
<MaterialDesignIcon icon-name="arrow-left" class="size-4" />
{{ $t("tools.back_to_tools") }}
@@ -47,7 +49,7 @@
<div
v-if="refreshing"
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 py-2.5 space-y-1.5"
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 space-y-2"
>
<div class="h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
@@ -60,9 +62,7 @@
</p>
</div>
<div
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
>
<div class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 space-y-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("tools.repository_server.http_heading") }}
</h2>
@@ -140,62 +140,70 @@
</div>
</div>
<div
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
>
<div class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 space-y-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("tools.repository_server.upload_heading") }}
</h2>
<label
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-600 text-white text-sm font-medium cursor-pointer hover:bg-sky-700 transition-colors"
>
<MaterialDesignIcon icon-name="upload" class="size-4" />
{{ $t("tools.repository_server.choose_file") }}
<input type="file" class="hidden" @change="onUpload" />
</label>
<p v-if="lastUploadError" class="text-xs text-red-600 dark:text-red-400">{{ lastUploadError }}</p>
<div class="flex flex-wrap items-center gap-3">
<label
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-600 text-white text-sm font-medium cursor-pointer hover:bg-sky-700 transition-colors"
>
<MaterialDesignIcon icon-name="upload" class="size-4" />
{{ $t("tools.repository_server.choose_file") }}
<input type="file" class="hidden" @change="onUpload" />
</label>
<p v-if="lastUploadError" class="text-xs text-red-600 dark:text-red-400">
{{ lastUploadError }}
</p>
</div>
</div>
<div
v-if="status?.last_refresh_failed && Object.keys(status.last_refresh_failed).length"
class="rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-200"
>
<div class="font-semibold mb-1">{{ $t("tools.repository_server.refresh_partial") }}</div>
<ul class="list-disc pl-4 space-y-1">
<li v-for="(msg, pkg) in status.last_refresh_failed" :key="pkg">
<span class="font-mono">{{ pkg }}</span
>: {{ msg }}
</li>
</ul>
</div>
<div
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6"
>
<div
class="px-4 py-3 border-b border-gray-100 dark:border-zinc-800 flex justify-between items-center"
class="rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-200"
>
<div class="font-semibold mb-1">{{ $t("tools.repository_server.refresh_partial") }}</div>
<ul class="list-disc pl-4 space-y-1">
<li v-for="(msg, pkg) in status.last_refresh_failed" :key="pkg">
<span class="font-mono">{{ pkg }}</span
>: {{ msg }}
</li>
</ul>
</div>
</div>
<div class="w-full py-4 sm:py-6 space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("tools.repository_server.files_heading") }}
</h2>
<span class="text-xs text-gray-500">{{ entries.length }}</span>
</div>
<div v-if="loading" class="p-6 text-center text-sm text-gray-500">
<div v-if="loading" class="text-center text-sm text-gray-500 py-6">
{{ $t("common.loading") }}
</div>
<div v-else-if="entries.length === 0" class="p-6 text-center text-sm text-gray-500">
<div v-else-if="entries.length === 0" class="text-center text-sm text-gray-500 py-6">
{{ $t("tools.repository_server.empty") }}
</div>
<table v-else class="w-full text-left text-xs">
<thead class="bg-gray-50 dark:bg-zinc-900/50 text-gray-500 uppercase tracking-wide">
<thead
class="text-gray-500 uppercase tracking-wide border-b border-gray-200/60 dark:border-zinc-800/60"
>
<tr>
<th class="px-4 py-2">{{ $t("tools.repository_server.col_name") }}</th>
<th class="px-4 py-2">{{ $t("tools.repository_server.col_source") }}</th>
<th class="px-4 py-2 text-right">{{ $t("tools.repository_server.col_size") }}</th>
<th class="px-4 py-2 w-24"></th>
<th class="px-4 py-2 font-semibold">{{ $t("tools.repository_server.col_name") }}</th>
<th class="px-4 py-2 font-semibold">{{ $t("tools.repository_server.col_source") }}</th>
<th class="px-4 py-2 font-semibold text-right">
{{ $t("tools.repository_server.col_size") }}
</th>
<th class="px-4 py-2 font-semibold w-24"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-zinc-800 text-gray-800 dark:text-zinc-200">
<tbody
class="divide-y divide-gray-100 dark:divide-zinc-800/50 text-gray-800 dark:text-zinc-200"
>
<tr v-for="row in entries" :key="row.name + row.source">
<td class="px-4 py-2 font-mono break-all">{{ row.name }}</td>
<td class="px-4 py-2">{{ row.source }}</td>