fix: LND UI CSS, QR codes, services tab, wallet creation, tx filtering

- LND UI: replace cdn.tailwindcss.com with local tailwind.css (CSP fix)
- LND UI: make asset paths relative for nginx proxy compatibility
- Web5 wallet: add QR code for on-chain receive addresses (qrcode npm)
- Web5 wallet: hide incoming transactions after 3 confirmations
- Apps: add "Services" tab to separate backend containers from user apps
- Home: null guard on packages.value to prevent TypeError on load
- First-boot: auto-create Bitcoin Knots wallet (no longer auto-created)
- AppSession: add mempool-electrs to port mapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-16 15:34:04 +00:00
co-authored by Claude Opus 4.6
parent 367b483a72
commit 1f0d51865d
10 changed files with 720 additions and 39 deletions
+52 -3
View File
@@ -3,8 +3,17 @@
<!-- Desktop: tabs + search in one row -->
<div class="hidden md:flex items-center gap-4 mb-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn mode-switcher-btn-active">My Apps</RouterLink>
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
@click="activeTab = 'apps'"
>My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
@click="activeTab = 'services'"
>Services</button>
</div>
<input
v-model="searchQuery"
@@ -15,8 +24,21 @@
/>
</div>
<!-- Mobile: search only (tabs are in Dashboard tab bar) -->
<!-- Mobile: tabs + search -->
<div class="md:hidden mb-4">
<div class="mode-switcher mode-switcher-full mb-3">
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
@click="activeTab = 'apps'"
>My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
@click="activeTab = 'services'"
>Services</button>
</div>
<input
v-model="searchQuery"
type="text"
@@ -257,6 +279,28 @@ import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const store = useAppStore()
// Tabs
const activeTab = ref<'apps' | 'services'>('apps')
// Service container name patterns (backend/infra, not user-facing)
// Exact container names or prefixes that are backend services (not user-facing)
const SERVICE_NAMES = new Set([
'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
'immich_postgres', 'immich_redis',
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'mysql-mempool',
])
function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
const lower = id.toLowerCase()
return lower.includes('_db') || lower.includes('-db') && !lower.includes('indeedhub')
? SERVICE_NAMES.has(id)
: false
}
// Search
const searchQuery = ref('')
@@ -337,7 +381,12 @@ const packages = computed(() => {
// Web-only apps first (alphabetically), then all other apps (alphabetically)
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
return entries.sort(([idA, a], [idB, b]) => {
// Filter by active tab
const filtered = entries.filter(([id]) => {
const isSvc = isServiceContainer(id)
return activeTab.value === 'services' ? isSvc : !isSvc
})
return filtered.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1
const bWeb = isWebOnlyApp(idB) ? 0 : 1
if (aWeb !== bWeb) return aWeb - bWeb
+2 -2
View File
@@ -485,9 +485,9 @@ watch(() => loginTransition.startWelcomeTyping, (shouldStart) => {
}, { immediate: true })
const packages = computed(() => store.packages)
const appCount = computed(() => Object.keys(packages.value).length)
const appCount = computed(() => Object.keys(packages.value || {}).length)
const runningCount = computed(() =>
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length
)
const quickLaunchApps = [
+16 -1
View File
@@ -1675,6 +1675,7 @@
<!-- On-chain: new address -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="receiveOnchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
<p class="text-sm font-mono text-white/90 break-all">{{ receiveOnchainAddress }}</p>
<button @click="copyToClipboard(receiveOnchainAddress, 'Address copied')" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
@@ -2507,7 +2508,7 @@ const walletTransactions = ref<WalletTransaction[]>([])
const showIncomingTxPanel = ref(false)
const incomingTransactions = computed(() =>
walletTransactions.value.filter(tx => tx.direction === 'incoming')
walletTransactions.value.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
@@ -2737,6 +2738,7 @@ const receiveInvoiceAmount = ref<number>(0)
const receiveInvoiceMemo = ref('')
const receiveInvoiceResult = ref('')
const receiveOnchainAddress = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const unifiedReceiveProcessing = ref(false)
const unifiedReceiveError = ref('')
@@ -2909,6 +2911,7 @@ async function unifiedReceive() {
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
receiveOnchainAddress.value = res.address
nextTick(() => renderQrCode(res.address, onchainQrCanvas.value))
} else {
if (!ecashReceiveToken.value.trim()) {
unifiedReceiveError.value = t('web5.pasteEcashToken')
@@ -2954,6 +2957,18 @@ function copyToClipboard(text: string, msg: string) {
showIdentityToast(msg)
}
async function renderQrCode(data: string, canvas: HTMLCanvasElement | null) {
if (!canvas || !data) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(canvas, `bitcoin:${data}`, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
} catch { /* QR rendering failed silently */ }
}
// --- Shared Content ---
interface ContentItemData {
id: string