Initial commit
This commit is contained in:
742
src/pages/AddressesPage.vue
Normal file
742
src/pages/AddressesPage.vue
Normal file
@@ -0,0 +1,742 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, shallowRef } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
BlockchainElectrum,
|
||||
WalletP2PKHWatch,
|
||||
WalletHDWatch,
|
||||
HDPublicNode,
|
||||
PublicKey,
|
||||
type Address,
|
||||
} from '@xocash/stack';
|
||||
|
||||
import { ReactiveWallet } from '../services/wallet.js';
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Types
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
type WalletType = 'p2pkh' | 'hd';
|
||||
|
||||
type AddressInfo = {
|
||||
address: Address;
|
||||
chainPath: number;
|
||||
index: number;
|
||||
cashAddr: string;
|
||||
derivationPath: string;
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Route & Router
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// State
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** The wallet key from the route param. */
|
||||
const key = computed(() => route.params.key as string);
|
||||
|
||||
/** The account path from query params (for master xpubs). */
|
||||
const accountPath = computed(() => route.query.accountPath as string | undefined);
|
||||
|
||||
/** The detected wallet type. */
|
||||
const walletType = ref<WalletType | null>(null);
|
||||
|
||||
/** Loading state. */
|
||||
const isLoading = ref(true);
|
||||
|
||||
/** Error message if wallet creation fails. */
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/** The reactive wallet instance. */
|
||||
const wallet = shallowRef<ReactiveWallet | null>(null);
|
||||
|
||||
/** The blockchain adapter (shared). */
|
||||
let blockchain: BlockchainElectrum | null = null;
|
||||
|
||||
/** Derived addresses with metadata. */
|
||||
const addressList = ref<AddressInfo[]>([]);
|
||||
|
||||
/** Filter for chain path (0 = external, 1 = internal, null = all). */
|
||||
const chainPathFilter = ref<number | null>(null);
|
||||
|
||||
/** Search query for filtering addresses. */
|
||||
const searchQuery = ref('');
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Wallet Type Detection
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
function isXPub(key: string): boolean {
|
||||
return key.startsWith('xpub') || key.startsWith('tpub');
|
||||
}
|
||||
|
||||
function isPublicKey(key: string): boolean {
|
||||
if (key.length !== 66) return false;
|
||||
if (!key.startsWith('02') && !key.startsWith('03')) return false;
|
||||
return /^[0-9a-fA-F]+$/.test(key);
|
||||
}
|
||||
|
||||
function detectWalletType(key: string): WalletType | null {
|
||||
if (isXPub(key)) return 'hd';
|
||||
if (isPublicKey(key)) return 'p2pkh';
|
||||
return null;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Wallet Creation
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
async function initializeWallet() {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const type = detectWalletType(key.value);
|
||||
|
||||
if (!type) {
|
||||
throw new Error(
|
||||
'Invalid key format. Please provide a valid public key or xpub.'
|
||||
);
|
||||
}
|
||||
|
||||
walletType.value = type;
|
||||
|
||||
blockchain = new BlockchainElectrum({
|
||||
servers: ['bch.imaginary.cash'],
|
||||
});
|
||||
|
||||
await blockchain.start();
|
||||
|
||||
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
|
||||
|
||||
if (type === 'hd') {
|
||||
let hdNode: HDPublicNode;
|
||||
try {
|
||||
hdNode = HDPublicNode.fromXPub(key.value);
|
||||
} catch {
|
||||
throw new Error('Invalid xpub format.');
|
||||
}
|
||||
|
||||
// Determine the xpub to use and the derivation path for display.
|
||||
let accountXpub: string;
|
||||
let displayPath: string;
|
||||
|
||||
if (accountPath.value) {
|
||||
// Master xpub provided - derive the account-level xpub.
|
||||
try {
|
||||
const accountNode = hdNode.derivePath(accountPath.value);
|
||||
accountXpub = accountNode.toXPub();
|
||||
displayPath = `m/${accountPath.value}`;
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to derive account path: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
// Already an account-level xpub.
|
||||
accountXpub = key.value;
|
||||
displayPath = "m/44'/145'/0'";
|
||||
}
|
||||
|
||||
baseWallet = await WalletHDWatch.from(
|
||||
{ blockchain },
|
||||
{
|
||||
xpub: accountXpub,
|
||||
derivationPath: displayPath,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
PublicKey.fromHex(key.value);
|
||||
} catch {
|
||||
throw new Error('Invalid public key format.');
|
||||
}
|
||||
|
||||
baseWallet = await WalletP2PKHWatch.from(
|
||||
{ blockchain },
|
||||
{ publicKey: key.value }
|
||||
);
|
||||
}
|
||||
|
||||
wallet.value = new ReactiveWallet(baseWallet);
|
||||
await wallet.value.start();
|
||||
|
||||
// Build the address list.
|
||||
buildAddressList();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
|
||||
console.error('Wallet initialization error:', err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the address list from the wallet.
|
||||
*/
|
||||
function buildAddressList() {
|
||||
if (!wallet.value) return;
|
||||
|
||||
const addresses: AddressInfo[] = [];
|
||||
const baseWallet = wallet.value.wallet;
|
||||
|
||||
if (walletType.value === 'hd' && 'wallets' in baseWallet) {
|
||||
// HD Wallet - iterate through child wallets.
|
||||
const hdWallet = baseWallet as WalletHDWatch;
|
||||
const basePath = hdWallet.genesisData.derivationPath;
|
||||
|
||||
for (const [chainPath, childWallets] of Object.entries(hdWallet.wallets)) {
|
||||
childWallets.forEach((childWallet, index) => {
|
||||
const address = childWallet.publicKey.deriveAddress();
|
||||
const chainPathNum = Number(chainPath);
|
||||
addresses.push({
|
||||
address,
|
||||
chainPath: chainPathNum,
|
||||
index,
|
||||
cashAddr: address.toCashAddr(),
|
||||
derivationPath: `${basePath}/${chainPathNum}/${index}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// P2PKH Wallet - single address.
|
||||
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
|
||||
const address = p2pkhWallet.publicKey.deriveAddress();
|
||||
addresses.push({
|
||||
address,
|
||||
chainPath: 0,
|
||||
index: 0,
|
||||
cashAddr: address.toCashAddr(),
|
||||
derivationPath: '—',
|
||||
});
|
||||
}
|
||||
|
||||
addressList.value = addresses;
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
if (wallet.value) {
|
||||
await wallet.value.stop();
|
||||
await wallet.value.destroy();
|
||||
wallet.value = null;
|
||||
}
|
||||
|
||||
if (blockchain) {
|
||||
await blockchain.stop();
|
||||
blockchain = null;
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
onMounted(() => {
|
||||
initializeWallet();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Computed Values
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** Filtered addresses based on chain path and search query. */
|
||||
const filteredAddresses = computed(() => {
|
||||
let result = addressList.value;
|
||||
|
||||
// Filter by chain path.
|
||||
if (chainPathFilter.value !== null) {
|
||||
result = result.filter((a) => a.chainPath === chainPathFilter.value);
|
||||
}
|
||||
|
||||
// Filter by search query.
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
result = result.filter((a) => a.cashAddr.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/** Count of external addresses. */
|
||||
const externalCount = computed(() =>
|
||||
addressList.value.filter((a) => a.chainPath === 0).length
|
||||
);
|
||||
|
||||
/** Count of internal (change) addresses. */
|
||||
const internalCount = computed(() =>
|
||||
addressList.value.filter((a) => a.chainPath === 1).length
|
||||
);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Actions
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
function goBack() {
|
||||
router.push({
|
||||
name: 'wallet',
|
||||
params: { key: key.value },
|
||||
query: accountPath.value ? { accountPath: accountPath.value } : {},
|
||||
});
|
||||
}
|
||||
|
||||
function copyAddress(cashAddr: string) {
|
||||
navigator.clipboard.writeText(cashAddr);
|
||||
}
|
||||
|
||||
function getChainLabel(chainPath: number): string {
|
||||
return chainPath === 0 ? 'External' : 'Change';
|
||||
}
|
||||
|
||||
function setFilter(filter: number | null) {
|
||||
chainPathFilter.value = filter;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="addresses-page">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-button" @click="goBack">
|
||||
← Back to Wallet
|
||||
</button>
|
||||
<div class="wallet-type-badge" v-if="walletType">
|
||||
{{ walletType === 'hd' ? 'HD Wallet' : 'P2PKH Wallet' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading addresses...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h2>Error</h2>
|
||||
<p>{{ error }}</p>
|
||||
<button class="retry-button" @click="goBack">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Addresses Content -->
|
||||
<div v-else class="addresses-content">
|
||||
<h1 class="page-title">Addresses</h1>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row" v-if="walletType === 'hd'">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ addressList.length }}</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ externalCount }}</div>
|
||||
<div class="stat-label">External</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ internalCount }}</div>
|
||||
<div class="stat-label">Change</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters" v-if="walletType === 'hd'">
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
:class="['filter-btn', { active: chainPathFilter === null }]"
|
||||
@click="setFilter(null)"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
:class="['filter-btn', { active: chainPathFilter === 0 }]"
|
||||
@click="setFilter(0)"
|
||||
>
|
||||
External
|
||||
</button>
|
||||
<button
|
||||
:class="['filter-btn', { active: chainPathFilter === 1 }]"
|
||||
@click="setFilter(1)"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search addresses..."
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Address List -->
|
||||
<div class="address-list">
|
||||
<div
|
||||
v-for="(addr, idx) in filteredAddresses"
|
||||
:key="addr.cashAddr"
|
||||
class="address-item"
|
||||
>
|
||||
<div class="address-info">
|
||||
<div class="address-index">
|
||||
<span class="chain-badge" :class="addr.chainPath === 0 ? 'external' : 'internal'">
|
||||
{{ getChainLabel(addr.chainPath) }}
|
||||
</span>
|
||||
<span class="index-number">#{{ addr.index }}</span>
|
||||
<span class="derivation-path">{{ addr.derivationPath }}</span>
|
||||
</div>
|
||||
<code class="address-value">{{ addr.cashAddr }}</code>
|
||||
</div>
|
||||
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredAddresses.length === 0" class="empty-state">
|
||||
<p v-if="searchQuery">No addresses match your search.</p>
|
||||
<p v-else>No addresses found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.addresses-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
|
||||
color: #fff;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #a0aec0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.wallet-type-badge {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid #2d3748;
|
||||
border-top-color: #00d4ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
color: #a0aec0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-state h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
color: #fc8181;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #2d3748;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: #4a5568;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.addresses-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Stats Row */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #a0aec0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
|
||||
/* Address List */
|
||||
.address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.address-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.address-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.address-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.address-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chain-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chain-badge.external {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
.chain-badge.internal {
|
||||
background: rgba(237, 137, 54, 0.2);
|
||||
color: #ed8936;
|
||||
}
|
||||
|
||||
.index-number {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.derivation-path {
|
||||
font-size: 0.7rem;
|
||||
color: #4a5568;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.address-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #a0aec0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #718096;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #2d3748;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user