feat(layouts): add app header with blockchain and account selectors

This commit is contained in:
2026-01-05 15:02:50 -05:00
parent d13b2c088d
commit 019feeb759
5 changed files with 767 additions and 395 deletions

View File

@@ -5,6 +5,66 @@ type WalletUser struct {
Address string Address string
} }
// Blockchain represents a supported blockchain network
type Blockchain struct {
ID string
Name string
Symbol string
Icon string
Color string
Testnet bool
}
// Account represents a wallet account
type Account struct {
ID string
Name string
Address string
Initials string
Color string
Active bool
}
// AppContext holds navigation state for the header
type AppContext struct {
User WalletUser
Blockchains []Blockchain
Accounts []Account
SelectedChainID string
SelectedAccountID string
}
// DefaultBlockchains returns the available blockchain networks
func DefaultBlockchains() []Blockchain {
return []Blockchain{
{ID: "sonr", Name: "Sonr", Symbol: "SNR", Icon: "cube", Color: "var(--wa-color-primary)", Testnet: false},
{ID: "ethereum", Name: "Ethereum", Symbol: "ETH", Icon: "ethereum", Color: "#627eea", Testnet: false},
{ID: "avalanche", Name: "Avalanche", Symbol: "AVAX", Icon: "mountain", Color: "#e84142", Testnet: false},
{ID: "polygon", Name: "Polygon", Symbol: "MATIC", Icon: "hexagon", Color: "#8247e5", Testnet: false},
{ID: "sonr-testnet", Name: "Sonr Testnet", Symbol: "tSNR", Icon: "cube", Color: "var(--wa-color-warning)", Testnet: true},
}
}
// DefaultAccounts returns the user's wallet accounts
func DefaultAccounts() []Account {
return []Account{
{ID: "main", Name: "Main Wallet", Address: "sonr1x9f...7k2m", Initials: "M", Color: "var(--wa-color-primary)", Active: true},
{ID: "trading", Name: "Trading", Address: "sonr1k4m...9p3q", Initials: "T", Color: "var(--wa-color-success)", Active: false},
{ID: "savings", Name: "Savings", Address: "sonr1r7t...2w8x", Initials: "S", Color: "var(--wa-color-warning)", Active: false},
}
}
// DefaultAppContext returns the default navigation context
func DefaultAppContext() AppContext {
return AppContext{
User: WalletUser{Name: "Sonr Wallet", Address: "sonr1x9f...7k2m"},
Blockchains: DefaultBlockchains(),
Accounts: DefaultAccounts(),
SelectedChainID: "sonr",
SelectedAccountID: "main",
}
}
templ AppLayout(title string, user WalletUser) { templ AppLayout(title string, user WalletUser) {
@Base(title) { @Base(title) {
@appStyles() @appStyles()
@@ -22,19 +82,71 @@ templ AppLayout(title string, user WalletUser) {
} }
templ AppHeader(user WalletUser) { templ AppHeader(user WalletUser) {
@AppHeaderWithContext(DefaultAppContext())
}
templ AppHeaderWithContext(ctx AppContext) {
<div class="header-wrapper"> <div class="header-wrapper">
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="header-left">
<wa-icon name="cube" style="font-size: 24px; color: var(--wa-color-primary);"></wa-icon> <wa-breadcrumb>
<span class="logo-text">Sonr Wallet</span> <span slot="separator">/</span>
<!-- Sonr Logo -->
<wa-breadcrumb-item>
<a href="/dashboard" class="breadcrumb-logo">
<wa-icon name="cube" style="font-size: 20px; color: var(--wa-color-primary);"></wa-icon>
<span>Sonr</span>
</a>
</wa-breadcrumb-item>
<!-- Blockchain Selector -->
<wa-breadcrumb-item>
<wa-combobox placeholder="Network" value={ ctx.SelectedChainID } size="small" class="nav-combobox">
<small>Mainnets</small>
for _, chain := range ctx.Blockchains {
if !chain.Testnet {
<wa-option value={ chain.ID }>
<wa-icon slot="prefix" name={ chain.Icon } style={ "color: " + chain.Color + ";" }></wa-icon>
{ chain.Name }
</wa-option>
}
}
<wa-divider></wa-divider>
<small>Testnets</small>
for _, chain := range ctx.Blockchains {
if chain.Testnet {
<wa-option value={ chain.ID }>
<wa-icon slot="prefix" name={ chain.Icon } style={ "color: " + chain.Color + ";" }></wa-icon>
{ chain.Name }
</wa-option>
}
}
</wa-combobox>
</wa-breadcrumb-item>
<!-- Account Selector -->
<wa-breadcrumb-item>
<wa-combobox placeholder="Account" value={ ctx.SelectedAccountID } size="small" class="nav-combobox">
for _, account := range ctx.Accounts {
<wa-option value={ account.ID }>
<wa-avatar slot="prefix" initials={ account.Initials } style={ "--size: 20px; background: " + account.Color + ";" }></wa-avatar>
{ account.Name }
</wa-option>
}
<wa-divider></wa-divider>
<wa-option value="__create__">
<wa-icon slot="prefix" name="plus"></wa-icon>
Create Account
</wa-option>
</wa-combobox>
</wa-breadcrumb-item>
</wa-breadcrumb>
</div> </div>
<div class="header-right"> <div class="header-right">
<wa-dropdown> <wa-dropdown>
<wa-avatar slot="trigger" initials="S" style="--size: 36px; background: var(--wa-color-primary); cursor: pointer;"></wa-avatar> <wa-avatar slot="trigger" initials="S" style="--size: 36px; background: var(--wa-color-primary); cursor: pointer;"></wa-avatar>
<div style="padding: var(--wa-space-m); border-bottom: 1px solid var(--wa-color-neutral-200);"> <div style="padding: var(--wa-space-m); border-bottom: 1px solid var(--wa-color-neutral-200);">
<div class="wa-stack wa-gap-0"> <div class="wa-stack wa-gap-0">
<span class="wa-heading-s">{ user.Name }</span> <span class="wa-heading-s">{ ctx.User.Name }</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ user.Address }</span> <span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ ctx.User.Address }</span>
</div> </div>
</div> </div>
<wa-dropdown-item href="/connections"> <wa-dropdown-item href="/connections">
@@ -95,11 +207,6 @@ templ appStyles() {
align-items: center; align-items: center;
gap: var(--wa-space-m); gap: var(--wa-space-m);
} }
.logo-text {
font-weight: 600;
font-size: var(--wa-font-size-l);
color: var(--wa-color-neutral-900);
}
.main-content { .main-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -109,5 +216,39 @@ templ appStyles() {
margin: 0 auto; margin: 0 auto;
padding: 0 var(--wa-space-l); padding: 0 var(--wa-space-l);
} }
/* Breadcrumb Navigation */
.header-left wa-breadcrumb {
--separator-color: var(--wa-color-neutral-400);
}
.header-left wa-breadcrumb::part(base) {
align-items: center;
}
.breadcrumb-logo {
display: flex;
align-items: center;
gap: var(--wa-space-xs);
text-decoration: none;
color: var(--wa-color-neutral-900);
font-weight: 600;
font-size: var(--wa-font-size-m);
}
.breadcrumb-logo:hover {
color: var(--wa-color-primary);
}
.nav-combobox {
--wa-input-border-color: transparent;
--wa-input-background-color: var(--wa-color-surface-alt);
min-width: 140px;
}
.nav-combobox::part(combobox) {
border-radius: var(--wa-radius-m);
padding: var(--wa-space-2xs) var(--wa-space-s);
}
.nav-combobox:hover::part(combobox) {
background: var(--wa-color-neutral-100);
}
.nav-combobox::part(display-input) {
font-weight: 500;
}
</style> </style>
} }

View File

@@ -10,6 +10,7 @@ templ Base(title string) {
<title>{ title } - Sonr Motr Wallet</title> <title>{ title } - Sonr Motr Wallet</title>
<script src="https://kit.webawesome.com/47c7425b971f443c.js" crossorigin="anonymous"></script> <script src="https://kit.webawesome.com/47c7425b971f443c.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<style> <style>
:root { :root {
--wa-color-primary: #17c2ff; --wa-color-primary: #17c2ff;

View File

@@ -43,7 +43,7 @@ func Base(title string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Sonr Motr Wallet</title><script src=\"https://kit.webawesome.com/47c7425b971f443c.js\" crossorigin=\"anonymous\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js\"></script><style>\n\t\t\t\t:root {\n\t\t\t\t\t--wa-color-primary: #17c2ff;\n\t\t\t\t}\n\t\t\t\thtml, body {\n\t\t\t\t\tmin-height: 100%;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcursor: default;\n\t\t\t\t}\n\t\t\t\twa-button, a, [onclick], [hx-get], [hx-post] {\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t</style></head><body>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Sonr Motr Wallet</title><script src=\"https://kit.webawesome.com/47c7425b971f443c.js\" crossorigin=\"anonymous\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js\"></script><script src=\"https://cdn.jsdelivr.net/npm/d3@7\"></script><style>\n\t\t\t\t:root {\n\t\t\t\t\t--wa-color-primary: #17c2ff;\n\t\t\t\t}\n\t\t\t\thtml, body {\n\t\t\t\t\tmin-height: 100%;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcursor: default;\n\t\t\t\t}\n\t\t\t\twa-button, a, [onclick], [hx-get], [hx-post] {\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t</style></head><body>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -1,6 +1,9 @@
package views package views
import "nebula/layouts" import (
"nebula/components"
"nebula/layouts"
)
type Token struct { type Token struct {
Symbol string Symbol string
@@ -37,11 +40,13 @@ type NFT struct {
} }
type DashboardData struct { type DashboardData struct {
TotalBalance string TotalBalance string
Change24h string Change24h string
Tokens []Token Tokens []Token
Transactions []Transaction Transactions []Transaction
NFTs []NFT NFTs []NFT
PortfolioChart []components.AreaSeriesData
MarketCapBubble []components.BubbleData
} }
func DefaultDashboardData() DashboardData { func DefaultDashboardData() DashboardData {
@@ -63,6 +68,70 @@ func DefaultDashboardData() DashboardData {
{Name: "Bored Ape #4521", Collection: "Bored Ape Yacht Club", Image: "https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?w=400&h=400&fit=crop", Floor: "28.5 ETH", Value: "32.0 ETH", Badge: "Listed", Verified: true}, {Name: "Bored Ape #4521", Collection: "Bored Ape Yacht Club", Image: "https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?w=400&h=400&fit=crop", Floor: "28.5 ETH", Value: "32.0 ETH", Badge: "Listed", Verified: true},
{Name: "Sonr Genesis #001", Collection: "Sonr Genesis", Image: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=400&fit=crop", Floor: "0.8 ETH", Value: "2.5 ETH", Badge: "Rare", Verified: true}, {Name: "Sonr Genesis #001", Collection: "Sonr Genesis", Image: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=400&fit=crop", Floor: "0.8 ETH", Value: "2.5 ETH", Badge: "Rare", Verified: true},
}, },
PortfolioChart: DefaultPortfolioChartData(),
MarketCapBubble: DefaultMarketCapBubbleData(),
}
}
// DefaultPortfolioChartData returns sample stacked area data for portfolio performance by asset
func DefaultPortfolioChartData() []components.AreaSeriesData {
return []components.AreaSeriesData{
// Day 1
{Date: "2024-12-25", Industry: "ETH", Value: 3200},
{Date: "2024-12-25", Industry: "SNR", Value: 2100},
{Date: "2024-12-25", Industry: "USDC", Value: 1200},
{Date: "2024-12-25", Industry: "AVAX", Value: 500},
// Day 2
{Date: "2024-12-26", Industry: "ETH", Value: 3350},
{Date: "2024-12-26", Industry: "SNR", Value: 2200},
{Date: "2024-12-26", Industry: "USDC", Value: 1200},
{Date: "2024-12-26", Industry: "AVAX", Value: 520},
// Day 3
{Date: "2024-12-27", Industry: "ETH", Value: 3500},
{Date: "2024-12-27", Industry: "SNR", Value: 2350},
{Date: "2024-12-27", Industry: "USDC", Value: 1200},
{Date: "2024-12-27", Industry: "AVAX", Value: 480},
// Day 4
{Date: "2024-12-28", Industry: "ETH", Value: 3280},
{Date: "2024-12-28", Industry: "SNR", Value: 2400},
{Date: "2024-12-28", Industry: "USDC", Value: 1200},
{Date: "2024-12-28", Industry: "AVAX", Value: 510},
// Day 5
{Date: "2024-12-29", Industry: "ETH", Value: 3450},
{Date: "2024-12-29", Industry: "SNR", Value: 2550},
{Date: "2024-12-29", Industry: "USDC", Value: 1250},
{Date: "2024-12-29", Industry: "AVAX", Value: 530},
// Day 6
{Date: "2024-12-30", Industry: "ETH", Value: 3600},
{Date: "2024-12-30", Industry: "SNR", Value: 2680},
{Date: "2024-12-30", Industry: "USDC", Value: 1250},
{Date: "2024-12-30", Industry: "AVAX", Value: 550},
// Day 7
{Date: "2024-12-31", Industry: "ETH", Value: 3750},
{Date: "2024-12-31", Industry: "SNR", Value: 2800},
{Date: "2024-12-31", Industry: "USDC", Value: 1250},
{Date: "2024-12-31", Industry: "AVAX", Value: 580},
// Day 8
{Date: "2025-01-01", Industry: "ETH", Value: 3680},
{Date: "2025-01-01", Industry: "SNR", Value: 2900},
{Date: "2025-01-01", Industry: "USDC", Value: 1250},
{Date: "2025-01-01", Industry: "AVAX", Value: 600},
}
}
// DefaultMarketCapBubbleData returns sample bubble chart data for market cap dominance
func DefaultMarketCapBubbleData() []components.BubbleData {
return []components.BubbleData{
{Name: "BTC", Sector: "Layer 1", Value: 45000},
{Name: "ETH", Sector: "Layer 1", Value: 28000},
{Name: "SNR", Sector: "Layer 1", Value: 8500},
{Name: "SOL", Sector: "Layer 1", Value: 12000},
{Name: "AVAX", Sector: "Layer 1", Value: 6000},
{Name: "USDC", Sector: "Stablecoin", Value: 15000},
{Name: "USDT", Sector: "Stablecoin", Value: 18000},
{Name: "UNI", Sector: "DeFi", Value: 4500},
{Name: "AAVE", Sector: "DeFi", Value: 3200},
{Name: "LINK", Sector: "Oracle", Value: 5800},
} }
} }
@@ -71,9 +140,9 @@ templ DashboardPage(data DashboardData, activeTab string) {
@dashboardStyles() @dashboardStyles()
<div class="dashboard-tabs"> <div class="dashboard-tabs">
<wa-tab-group id="dashboard-tabs"> <wa-tab-group id="dashboard-tabs">
<wa-tab panel="accounts" if activeTab == "accounts" || activeTab == "" { active?={ true } }> <wa-tab panel="overview" if activeTab == "overview" || activeTab == "" { active?={ true } }>
<wa-icon name="wallet"></wa-icon> <wa-icon name="grid-2"></wa-icon>
Accounts Overview
</wa-tab> </wa-tab>
<wa-tab panel="transactions" if activeTab == "transactions" { active?={ true } }> <wa-tab panel="transactions" if activeTab == "transactions" { active?={ true } }>
<wa-icon name="arrow-right-arrow-left"></wa-icon> <wa-icon name="arrow-right-arrow-left"></wa-icon>
@@ -91,8 +160,8 @@ templ DashboardPage(data DashboardData, activeTab string) {
<wa-icon name="chart-line"></wa-icon> <wa-icon name="chart-line"></wa-icon>
Activity Activity
</wa-tab> </wa-tab>
<wa-tab-panel name="accounts"> <wa-tab-panel name="overview">
@AccountsPanel(data) @OverviewPanel(data)
</wa-tab-panel> </wa-tab-panel>
<wa-tab-panel name="transactions"> <wa-tab-panel name="transactions">
@TransactionsPanel(data.Transactions) @TransactionsPanel(data.Transactions)
@@ -108,6 +177,9 @@ templ DashboardPage(data DashboardData, activeTab string) {
</wa-tab-panel> </wa-tab-panel>
</wa-tab-group> </wa-tab-group>
</div> </div>
<!-- Drawers -->
@components.AllDrawers(components.DefaultTokenOptions(), "sonr1x9f4h2k8m3n5p7q2r4s6t8v0w3x5y7z9a1b3c5d7k2m")
@components.DrawerTriggers()
} }
} }
@@ -119,15 +191,15 @@ templ AccountsPanel(data DashboardData) {
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Manage your crypto assets and balances</span> <span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Manage your crypto assets and balances</span>
</div> </div>
<div class="quick-actions"> <div class="quick-actions">
<wa-button variant="neutral" appearance="outlined" size="small"> <wa-button variant="neutral" appearance="outlined" size="small" data-drawer-open="receive">
<wa-icon slot="start" name="arrow-down"></wa-icon> <wa-icon slot="start" name="arrow-down"></wa-icon>
Receive Receive
</wa-button> </wa-button>
<wa-button variant="neutral" appearance="outlined" size="small"> <wa-button variant="neutral" appearance="outlined" size="small" data-drawer-open="send">
<wa-icon slot="start" name="arrow-up"></wa-icon> <wa-icon slot="start" name="arrow-up"></wa-icon>
Send Send
</wa-button> </wa-button>
<wa-button variant="brand" size="small"> <wa-button variant="brand" size="small" data-drawer-open="swap">
<wa-icon slot="start" name="arrow-right-arrow-left"></wa-icon> <wa-icon slot="start" name="arrow-right-arrow-left"></wa-icon>
Swap Swap
</wa-button> </wa-button>
@@ -163,6 +235,22 @@ templ AccountsPanel(data DashboardData) {
</div> </div>
</wa-card> </wa-card>
</div> </div>
<!-- Portfolio Performance Chart -->
<wa-card style="margin-bottom: var(--wa-space-l);">
<div slot="header" class="wa-flank">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-m">Portfolio Performance</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Asset allocation over time</span>
</div>
<wa-radio-group value="1w" orientation="horizontal">
<wa-radio appearance="button" value="1d">1D</wa-radio>
<wa-radio appearance="button" value="1w">1W</wa-radio>
<wa-radio appearance="button" value="1m">1M</wa-radio>
<wa-radio appearance="button" value="1y">1Y</wa-radio>
</wa-radio-group>
</div>
@components.StackedAreaChart("portfolio-chart", data.PortfolioChart)
</wa-card>
<div class="wa-grid" style="--min-column-size: 360px; gap: var(--wa-space-l);"> <div class="wa-grid" style="--min-column-size: 360px; gap: var(--wa-space-l);">
<wa-card> <wa-card>
<div slot="header" class="wa-flank"> <div slot="header" class="wa-flank">
@@ -637,6 +725,10 @@ func nftBadgeVariant(badge string) string {
} }
templ ActivityPanel() { templ ActivityPanel() {
@ActivityPanelWithData(DefaultMarketCapBubbleData())
}
templ ActivityPanelWithData(marketCapData []components.BubbleData) {
<header style="margin-bottom: var(--wa-space-l);"> <header style="margin-bottom: var(--wa-space-l);">
<div class="wa-flank"> <div class="wa-flank">
<div class="wa-stack wa-gap-2xs"> <div class="wa-stack wa-gap-2xs">
@@ -660,6 +752,18 @@ templ ActivityPanel() {
@ActiveSessionsCard() @ActiveSessionsCard()
@ConnectedAppsCard() @ConnectedAppsCard()
</div> </div>
<!-- Market Dominance Chart -->
<wa-card style="margin-top: var(--wa-space-l);">
<div slot="header" class="wa-flank">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-m">Market Cap Dominance</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Portfolio allocation by market cap</span>
</div>
</div>
<div style="padding: var(--wa-space-m);">
@components.BubbleChart("market-cap-bubble", marketCapData)
</div>
</wa-card>
} }
templ ActivityStatCard(icon string, label string, value string, color string) { templ ActivityStatCard(icon string, label string, value string, color string) {

File diff suppressed because one or more lines are too long