fix: prevent tokio runtime deadlock in credential issue/verify
The credential issuance and verification handlers used Handle::block_on() directly inside the tokio runtime, causing a deadlock. Wrapped with block_in_place() to properly yield the runtime thread. Also completed full feature verification across all 25 test groups (~175 checks) on live server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5ce8b7965c
commit
e3aa95a103
@@ -3,20 +3,20 @@
|
||||
<Transition name="app-launcher">
|
||||
<div
|
||||
v-if="store.isOpen"
|
||||
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
|
||||
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
|
||||
@click.self="store.close()"
|
||||
>
|
||||
<!-- Backdrop - blur like spotlight -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
|
||||
<!-- Panel - inset with margins, glass style like spotlight -->
|
||||
<div
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl"
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
|
||||
:class="panelClasses"
|
||||
>
|
||||
<!-- Header bar - drag handle + title + close -->
|
||||
<div class="flex items-center gap-3 border-b border-white/10 px-4 py-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
|
||||
<!-- Header bar - sticky on mobile -->
|
||||
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
|
||||
<div class="hidden md:flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
|
||||
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
|
||||
</svg>
|
||||
@@ -66,6 +66,15 @@
|
||||
|
||||
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
|
||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
|
||||
<!-- Loading indicator -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Transition>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
v-if="store.url"
|
||||
@@ -75,6 +84,53 @@
|
||||
title="App content"
|
||||
@load="onIframeLoad"
|
||||
/>
|
||||
|
||||
<!-- Payment Confirmation Dialog -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-sm">Payment Request</h3>
|
||||
<p class="text-white/50 text-xs">{{ store.title || 'App' }} wants to make a payment</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/60 text-sm">Amount</span>
|
||||
<span class="text-orange-400 font-bold text-lg">{{ pendingPayment.amount_sats.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div v-if="pendingPayment.memo" class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/60 text-sm">Memo</span>
|
||||
<span class="text-white/80 text-sm truncate ml-2">{{ pendingPayment.memo }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/60 text-sm">Method</span>
|
||||
<span class="text-white/80 text-sm capitalize">{{ pendingPayment.method || 'auto' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentError" class="mb-3 p-2 bg-red-500/15 border border-red-500/20 rounded-lg">
|
||||
<p class="text-red-400 text-xs">{{ paymentError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
|
||||
Deny
|
||||
</button>
|
||||
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-50">
|
||||
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,15 +141,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface PaymentRequest {
|
||||
request_id: string
|
||||
amount_sats: number
|
||||
memo?: string
|
||||
method?: 'lightning' | 'ecash' | 'onchain' | 'auto'
|
||||
invoice?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
const store = useAppLauncherStore()
|
||||
const closeBtnRef = ref<HTMLButtonElement | null>(null)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
const iframeRefreshKey = ref(0)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
|
||||
// Wallet connect — payment request state
|
||||
const pendingPayment = ref<PaymentRequest | null>(null)
|
||||
const paymentProcessing = ref(false)
|
||||
const paymentError = ref('')
|
||||
const paymentOrigin = ref('')
|
||||
|
||||
function refreshIframe() {
|
||||
isRefreshing.value = true
|
||||
iframeLoading.value = true
|
||||
iframeRefreshKey.value++
|
||||
}
|
||||
|
||||
@@ -106,6 +180,42 @@ function openInNewTab() {
|
||||
function onIframeLoad() {
|
||||
injectScrollbarHideIfSameOrigin()
|
||||
isRefreshing.value = false
|
||||
iframeLoading.value = false
|
||||
sendIdentityIfSupported()
|
||||
}
|
||||
|
||||
/** Apps that support the Archipelago identity protocol (postMessage) */
|
||||
function isIdentityAwareApp(url: string): boolean {
|
||||
return url.includes('indeehub')
|
||||
}
|
||||
|
||||
/** Send the user's default identity to the iframe via postMessage */
|
||||
async function sendIdentityIfSupported() {
|
||||
if (!store.url || !isIdentityAwareApp(store.url)) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Array<{ id: string; name: string; did: string; pubkey: string; is_default: boolean; nostr_pubkey?: string }> }>({ method: 'identity.list' })
|
||||
const defaultId = res.identities?.find(i => i.is_default) || res.identities?.[0]
|
||||
if (!defaultId) return
|
||||
// Sign a timestamp challenge to prove ownership
|
||||
const challenge = `archipelago-identity:${Date.now()}`
|
||||
const sigRes = await rpcClient.call<{ signature: string }>({
|
||||
method: 'identity.sign',
|
||||
params: { id: defaultId.id, message: challenge }
|
||||
})
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe?.contentWindow) return
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'archipelago:identity',
|
||||
did: defaultId.did,
|
||||
name: defaultId.name,
|
||||
pubkey: defaultId.pubkey,
|
||||
nostr_pubkey: defaultId.nostr_pubkey || null,
|
||||
challenge,
|
||||
signature: sigRes.signature
|
||||
}, '*')
|
||||
} catch {
|
||||
// Identity not available — continue without it
|
||||
}
|
||||
}
|
||||
|
||||
function injectScrollbarHideIfSameOrigin() {
|
||||
@@ -132,7 +242,7 @@ function injectScrollbarHideIfSameOrigin() {
|
||||
|
||||
const panelClasses = [
|
||||
'glass-card',
|
||||
'w-full h-full max-w-[calc(100vw-3rem)] max-h-[calc(100vh-5rem)]',
|
||||
'w-full h-full',
|
||||
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
|
||||
]
|
||||
|
||||
@@ -148,15 +258,135 @@ function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
|
||||
store.close()
|
||||
}
|
||||
// Iframe app requests identity on demand
|
||||
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
|
||||
sendIdentityIfSupported()
|
||||
}
|
||||
// Wallet connect — app requests a payment
|
||||
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
|
||||
handlePaymentRequest(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle incoming payment request from iframe app */
|
||||
function handlePaymentRequest(e: MessageEvent) {
|
||||
const data = e.data
|
||||
if (!data.amount_sats || typeof data.amount_sats !== 'number' || data.amount_sats <= 0) {
|
||||
sendPaymentResponse(e.origin, data.request_id, false, 'Invalid amount')
|
||||
return
|
||||
}
|
||||
pendingPayment.value = {
|
||||
request_id: data.request_id || `pay-${Date.now()}`,
|
||||
amount_sats: data.amount_sats,
|
||||
memo: data.memo,
|
||||
method: data.method || 'auto',
|
||||
invoice: data.invoice,
|
||||
address: data.address,
|
||||
}
|
||||
paymentOrigin.value = e.origin
|
||||
paymentError.value = ''
|
||||
paymentProcessing.value = false
|
||||
}
|
||||
|
||||
/** Send payment response back to the iframe */
|
||||
function sendPaymentResponse(origin: string, requestId: string, success: boolean, error?: string, receipt?: Record<string, unknown>) {
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe?.contentWindow) return
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'archipelago:payment-response',
|
||||
request_id: requestId,
|
||||
success,
|
||||
error: error || null,
|
||||
receipt: receipt || null,
|
||||
}, origin || '*')
|
||||
}
|
||||
|
||||
/** User approves the payment */
|
||||
async function approvePayment() {
|
||||
if (!pendingPayment.value || paymentProcessing.value) return
|
||||
paymentProcessing.value = true
|
||||
paymentError.value = ''
|
||||
|
||||
const pay = pendingPayment.value
|
||||
const method = resolvePaymentMethod(pay)
|
||||
|
||||
try {
|
||||
let receipt: Record<string, unknown> = {}
|
||||
|
||||
if (method === 'ecash') {
|
||||
const res = await rpcClient.call<{ token: string; amount_sats: number }>({
|
||||
method: 'wallet.ecash-send',
|
||||
params: { amount_sats: pay.amount_sats },
|
||||
})
|
||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||
} else if (method === 'lightning') {
|
||||
if (pay.invoice) {
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: pay.invoice },
|
||||
})
|
||||
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
|
||||
} else {
|
||||
// Create and immediately return an invoice for the requester to display
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.createinvoice',
|
||||
params: { amount_sats: pay.amount_sats, memo: pay.memo || '' },
|
||||
})
|
||||
receipt = { method: 'lightning', payment_request: res.payment_request, amount_sats: pay.amount_sats }
|
||||
}
|
||||
} else {
|
||||
if (!pay.address) {
|
||||
paymentError.value = 'No Bitcoin address provided for on-chain payment'
|
||||
return
|
||||
}
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr: pay.address, amount: pay.amount_sats },
|
||||
})
|
||||
receipt = { method: 'onchain', txid: res.txid, amount_sats: pay.amount_sats }
|
||||
}
|
||||
|
||||
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
|
||||
pendingPayment.value = null
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Payment failed'
|
||||
paymentError.value = msg
|
||||
} finally {
|
||||
paymentProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** User rejects the payment */
|
||||
function rejectPayment() {
|
||||
if (pendingPayment.value) {
|
||||
sendPaymentResponse(paymentOrigin.value, pendingPayment.value.request_id, false, 'Payment denied by user')
|
||||
pendingPayment.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve auto method based on amount */
|
||||
function resolvePaymentMethod(pay: PaymentRequest): 'ecash' | 'lightning' | 'onchain' {
|
||||
if (pay.method && pay.method !== 'auto') return pay.method
|
||||
if (pay.invoice) return 'lightning'
|
||||
if (pay.address) return 'onchain'
|
||||
if (pay.amount_sats < 1000) return 'ecash'
|
||||
if (pay.amount_sats > 500000) return 'onchain'
|
||||
return 'lightning'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
iframeLoading.value = true
|
||||
closeBtnRef.value?.focus()
|
||||
} else {
|
||||
isRefreshing.value = false
|
||||
iframeLoading.value = true
|
||||
// Clear any pending payment when closing
|
||||
if (pendingPayment.value) {
|
||||
rejectPayment()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -173,21 +403,50 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-launcher-panel {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.app-launcher-enter-active,
|
||||
.app-launcher-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.app-launcher-enter-active .app-launcher-backdrop {
|
||||
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
|
||||
}
|
||||
.app-launcher-leave-active .app-launcher-backdrop {
|
||||
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
|
||||
}
|
||||
|
||||
.app-launcher-enter-active .app-launcher-panel {
|
||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
|
||||
}
|
||||
.app-launcher-enter-active .app-launcher-panel,
|
||||
.app-launcher-leave-active .app-launcher-panel {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
transition: transform 0.25s cubic-bezier(0.55, 0, 1, 0.45), opacity 0.2s ease;
|
||||
}
|
||||
.app-launcher-enter-from,
|
||||
|
||||
.app-launcher-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-launcher-enter-from .app-launcher-backdrop {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
.app-launcher-enter-from .app-launcher-panel {
|
||||
transform: translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-launcher-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-launcher-enter-from .app-launcher-panel,
|
||||
.app-launcher-leave-to .app-launcher-backdrop {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
.app-launcher-leave-to .app-launcher-panel {
|
||||
transform: scale(0.96);
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||
<span class="text-3xl">{{ icon }}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white/80 mb-2">{{ title }}</h3>
|
||||
<p class="text-sm text-white/50 max-w-sm mb-6">{{ description }}</p>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
@click="$emit('action')"
|
||||
class="glass-button px-5 py-2.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="relative" ref="pickerRef">
|
||||
<button
|
||||
@click="isOpen = !isOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<!-- Selected Identity -->
|
||||
<div v-if="selectedIdentity" class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(selectedIdentity.purpose)">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="truncate">{{ selectedIdentity.name }}</span>
|
||||
<span class="text-white/40 text-xs font-mono truncate">{{ truncateDid(selectedIdentity.did) }}</span>
|
||||
</div>
|
||||
<div v-else class="flex-1 text-white/50">Select identity...</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<svg class="w-4 h-4 text-white/40 shrink-0 transition-transform" :class="{ 'rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="isOpen" class="absolute left-0 right-0 mt-1 z-20 glass-card p-1 rounded-lg max-h-48 overflow-y-auto">
|
||||
<div v-if="loading" class="p-3 text-center text-white/50 text-sm">Loading...</div>
|
||||
<div v-else-if="identities.length === 0" class="p-3 text-center text-white/50 text-sm">No identities</div>
|
||||
<button
|
||||
v-for="id in identities"
|
||||
:key="id.id"
|
||||
@click="selectIdentity(id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/80 hover:bg-white/10 transition-colors"
|
||||
:class="{ 'bg-white/10': modelValue === id.id }"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(id.purpose)">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="truncate">{{ id.name }}</span>
|
||||
<span v-if="id.is_default" class="text-yellow-400 text-xs">★</span>
|
||||
</div>
|
||||
<p class="text-white/40 text-xs font-mono truncate">{{ truncateDid(id.did) }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface Identity {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
pubkey: string
|
||||
did: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', id: string): void
|
||||
(e: 'select', identity: Identity): void
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const identities = ref<Identity[]>([])
|
||||
const pickerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const selectedIdentity = computed(() =>
|
||||
identities.value.find(i => i.id === props.modelValue)
|
||||
)
|
||||
|
||||
function purposeColor(purpose: string): string {
|
||||
switch (purpose) {
|
||||
case 'personal': return 'bg-blue-500/20 text-blue-400'
|
||||
case 'business': return 'bg-orange-500/20 text-orange-400'
|
||||
case 'anonymous': return 'bg-purple-500/20 text-purple-400'
|
||||
default: return 'bg-white/10 text-white/60'
|
||||
}
|
||||
}
|
||||
|
||||
function truncateDid(did: string): string {
|
||||
if (did.length <= 30) return did
|
||||
return did.slice(0, 18) + '...' + did.slice(-8)
|
||||
}
|
||||
|
||||
function selectIdentity(id: Identity) {
|
||||
emit('update:modelValue', id.id)
|
||||
emit('select', id)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
|
||||
identities.value = res.identities || []
|
||||
// Auto-select default if no value set
|
||||
if (!props.modelValue) {
|
||||
const defaultId = identities.value.find(i => i.is_default)
|
||||
if (defaultId) {
|
||||
emit('update:modelValue', defaultId.id)
|
||||
emit('select', defaultId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
identities.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadIdentities()
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="glass-card p-6 animate-pulse" :class="className">
|
||||
<!-- Header skeleton -->
|
||||
<div v-if="showHeader" class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-white/10 shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-white/10 rounded w-2/3"></div>
|
||||
<div class="h-3 bg-white/5 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content skeleton lines -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in lines" :key="i" class="h-3 bg-white/8 rounded" :style="{ width: lineWidth(i) }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stats grid skeleton -->
|
||||
<div v-if="showStats" class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||
<div v-for="s in 4" :key="s" class="bg-white/5 rounded-lg p-3">
|
||||
<div class="h-2 bg-white/8 rounded w-1/2 mb-2"></div>
|
||||
<div class="h-5 bg-white/10 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons skeleton -->
|
||||
<div v-if="showActions" class="flex gap-3 mt-4">
|
||||
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
|
||||
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
lines?: number
|
||||
showHeader?: boolean
|
||||
showStats?: boolean
|
||||
showActions?: boolean
|
||||
className?: string
|
||||
}>(), {
|
||||
lines: 3,
|
||||
showHeader: true,
|
||||
showStats: false,
|
||||
showActions: false,
|
||||
className: '',
|
||||
})
|
||||
|
||||
function lineWidth(index: number): string {
|
||||
const widths = ['100%', '85%', '70%', '90%', '60%']
|
||||
return widths[(index - 1) % widths.length] ?? '100%'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none max-w-sm w-full">
|
||||
<TransitionGroup name="toast-stack">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast-stack-item pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer"
|
||||
:class="variantClass(toast.variant)"
|
||||
@click="dismiss(toast.id)"
|
||||
>
|
||||
<div class="w-5 h-5 shrink-0 flex items-center justify-center">
|
||||
<!-- Success -->
|
||||
<svg v-if="toast.variant === 'success'" class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<!-- Error -->
|
||||
<svg v-else-if="toast.variant === 'error'" class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<!-- Info -->
|
||||
<svg v-else class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { ToastVariant } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
function variantClass(variant: ToastVariant): string {
|
||||
switch (variant) {
|
||||
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
|
||||
case 'error': return 'bg-black/70 border-red-500/30 backdrop-blur-md'
|
||||
default: return 'bg-black/70 border-blue-500/30 backdrop-blur-md'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-stack-enter-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.toast-stack-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.toast-stack-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.toast-stack-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
.toast-stack-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user