Improve web interface with dynamic status, navigation, and mobile support (#1073)

* Improve transaction confirmation UI: reorder elements, move buttons to details card, shorten button text
* feat: real node status

---------

Co-authored-by: thesimplekid <tsk@thesimplekid.com>
This commit is contained in:
Erik
2025-09-15 15:51:23 +02:00
committed by GitHub
parent 2c9333449a
commit 7d78240da5
9 changed files with 552 additions and 205 deletions

View File

@@ -15,7 +15,8 @@ use serde::Deserialize;
use crate::web::handlers::utils::deserialize_optional_u64; use crate::web::handlers::utils::deserialize_optional_u64;
use crate::web::handlers::AppState; use crate::web::handlers::AppState;
use crate::web::templates::{ use crate::web::templates::{
error_message, form_card, format_sats_as_btc, info_card, layout, success_message, error_message, form_card, format_sats_as_btc, info_card, is_node_running, layout_with_status,
success_message,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -43,7 +44,7 @@ pub async fn channels_page(State(_state): State<AppState>) -> Result<Response, S
.unwrap()) .unwrap())
} }
pub async fn open_channel_page(State(_state): State<AppState>) -> Result<Html<String>, StatusCode> { pub async fn open_channel_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let content = form_card( let content = form_card(
"Open New Channel", "Open New Channel",
html! { html! {
@@ -68,14 +69,18 @@ pub async fn open_channel_page(State(_state): State<AppState>) -> Result<Html<St
label for="push_btc" { "Push Amount (optional)" } label for="push_btc" { "Push Amount (optional)" }
input type="number" id="push_btc" name="push_btc" placeholder="₿0" step="1" {} input type="number" id="push_btc" name="push_btc" placeholder="₿0" step="1" {}
} }
button type="submit" { "Open Channel" } div class="form-actions" {
" " a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
a href="/balance" { button type="button" { "Cancel" } } button type="submit" class="button-primary" { "Open Channel" }
}
} }
}, },
); );
Ok(Html(layout("Open Channel", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Open Channel", content, is_running).into_string(),
))
} }
pub async fn post_open_channel( pub async fn post_open_channel(
@@ -105,7 +110,7 @@ pub async fn post_open_channel(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Open Channel Error", content).into_string(), layout_with_status("Open Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -125,7 +130,7 @@ pub async fn post_open_channel(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Open Channel Error", content).into_string(), layout_with_status("Open Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -149,7 +154,7 @@ pub async fn post_open_channel(
.status(StatusCode::INTERNAL_SERVER_ERROR) .status(StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Open Channel Error", content).into_string(), layout_with_status("Open Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -207,7 +212,7 @@ pub async fn post_open_channel(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Open Channel Result", content).into_string(), layout_with_status("Open Channel Result", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }
@@ -226,7 +231,9 @@ pub async fn close_channel_page(
a href="/balance" { button { "← Back to Lightning" } } a href="/balance" { button { "← Back to Lightning" } }
} }
}; };
return Ok(Html(layout("Close Channel Error", content).into_string())); return Ok(Html(
layout_with_status("Close Channel Error", content, true).into_string(),
));
} }
// Get channel information for amount display // Get channel information for amount display
@@ -267,7 +274,10 @@ pub async fn close_channel_page(
}, },
); );
Ok(Html(layout("Close Channel", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Close Channel", content, is_running).into_string(),
))
} }
pub async fn force_close_channel_page( pub async fn force_close_channel_page(
@@ -285,7 +295,7 @@ pub async fn force_close_channel_page(
} }
}; };
return Ok(Html( return Ok(Html(
layout("Force Close Channel Error", content).into_string(), layout_with_status("Force Close Channel Error", content, true).into_string(),
)); ));
} }
@@ -335,7 +345,10 @@ pub async fn force_close_channel_page(
}, },
); );
Ok(Html(layout("Force Close Channel", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Force Close Channel", content, is_running).into_string(),
))
} }
pub async fn post_close_channel( pub async fn post_close_channel(
@@ -365,7 +378,7 @@ pub async fn post_close_channel(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Close Channel Error", content).into_string(), layout_with_status("Close Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -385,7 +398,7 @@ pub async fn post_close_channel(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Close Channel Error", content).into_string(), layout_with_status("Close Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -436,7 +449,7 @@ pub async fn post_close_channel(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Close Channel Result", content).into_string(), layout_with_status("Close Channel Result", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }
@@ -468,7 +481,7 @@ pub async fn post_force_close_channel(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Force Close Channel Error", content).into_string(), layout_with_status("Force Close Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -488,7 +501,7 @@ pub async fn post_force_close_channel(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Force Close Channel Error", content).into_string(), layout_with_status("Force Close Channel Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -541,7 +554,7 @@ pub async fn post_force_close_channel(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Force Close Channel Result", content).into_string(), layout_with_status("Force Close Channel Result", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }

View File

@@ -5,7 +5,7 @@ use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus};
use maud::html; use maud::html;
use crate::web::handlers::AppState; use crate::web::handlers::AppState;
use crate::web::templates::{format_sats_as_btc, layout}; use crate::web::templates::{format_sats_as_btc, is_node_running, layout_with_status};
#[derive(Debug)] #[derive(Debug)]
pub struct UsageMetrics { pub struct UsageMetrics {
@@ -272,5 +272,8 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
} }
}; };
Ok(Html(layout("Dashboard", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Dashboard", content, is_running).into_string(),
))
} }

View File

@@ -10,7 +10,8 @@ use serde::Deserialize;
use crate::web::handlers::utils::{deserialize_optional_f64, deserialize_optional_u32}; use crate::web::handlers::utils::{deserialize_optional_f64, deserialize_optional_u32};
use crate::web::handlers::AppState; use crate::web::handlers::AppState;
use crate::web::templates::{ use crate::web::templates::{
error_message, form_card, format_sats_as_btc, info_card, layout, success_message, error_message, form_card, format_sats_as_btc, info_card, is_node_running, layout_with_status,
success_message,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -30,7 +31,7 @@ pub struct CreateBolt12Form {
expiry_seconds: Option<u32>, expiry_seconds: Option<u32>,
} }
pub async fn invoices_page(State(_state): State<AppState>) -> Result<Html<String>, StatusCode> { pub async fn invoices_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let content = html! { let content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" } h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" }
div class="grid" { div class="grid" {
@@ -78,7 +79,10 @@ pub async fn invoices_page(State(_state): State<AppState>) -> Result<Html<String
} }
}; };
Ok(Html(layout("Create Invoices", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Create Invoices", content, is_running).into_string(),
))
} }
pub async fn post_create_bolt11( pub async fn post_create_bolt11(
@@ -122,7 +126,7 @@ pub async fn post_create_bolt11(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Create Invoice Error", content).into_string(), layout_with_status("Create Invoice Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -193,7 +197,7 @@ pub async fn post_create_bolt11(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("BOLT11 Invoice Created", content).into_string(), layout_with_status("BOLT11 Invoice Created", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }
@@ -287,7 +291,7 @@ pub async fn post_create_bolt12(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("BOLT12 Offer Created", content).into_string(), layout_with_status("BOLT12 Offer Created", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }

View File

@@ -4,7 +4,7 @@ use axum::response::Html;
use maud::html; use maud::html;
use crate::web::handlers::utils::AppState; use crate::web::handlers::utils::AppState;
use crate::web::templates::{format_sats_as_btc, layout}; use crate::web::templates::{format_sats_as_btc, is_node_running, layout_with_status};
pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> { pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let balances = state.node.inner.list_balances(); let balances = state.node.inner.list_balances();
@@ -216,5 +216,8 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
} }
}; };
Ok(Html(layout("Lightning", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Lightning", content, is_running).into_string(),
))
} }

View File

@@ -13,7 +13,8 @@ use serde::{Deserialize, Serialize};
use crate::web::handlers::utils::deserialize_optional_u64; use crate::web::handlers::utils::deserialize_optional_u64;
use crate::web::handlers::AppState; use crate::web::handlers::AppState;
use crate::web::templates::{ use crate::web::templates::{
error_message, form_card, format_sats_as_btc, info_card, layout, success_message, error_message, form_card, format_sats_as_btc, info_card, is_node_running, layout_with_status,
success_message,
}; };
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@@ -32,54 +33,8 @@ pub struct ConfirmOnchainForm {
confirmed: Option<String>, confirmed: Option<String>,
} }
pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, StatusCode> { fn quick_actions_section() -> maud::Markup {
let address_result = state.node.inner.onchain_payment().new_address(); html! {
let content = match address_result {
Ok(address) => {
html! {
(success_message(&format!("New address generated: {address}")))
div class="card" {
h2 { "Bitcoin Address" }
div class="info-item" {
span class="info-label" { "Address:" }
span class="info-value" style="font-family: monospace; font-size: 0.9rem;" { (address.to_string()) }
}
}
div class="card" {
a href="/onchain" { button { "← Back to On-chain" } }
" "
a href="/onchain/new-address" { button { "Generate Another Address" } }
}
}
}
Err(e) => {
html! {
(error_message(&format!("Failed to generate address: {e}")))
div class="card" {
a href="/onchain" { button { "← Back to On-chain" } }
}
}
}
};
Ok(Html(layout("New Address", content).into_string()))
}
pub async fn onchain_page(
State(state): State<AppState>,
query: Query<HashMap<String, String>>,
) -> Result<Html<String>, StatusCode> {
let balances = state.node.inner.list_balances();
let action = query
.get("action")
.map(|s| s.as_str())
.unwrap_or("overview");
let mut content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
// Quick Actions section - individual cards
div class="card" style="margin-bottom: 2rem;" { div class="card" style="margin-bottom: 2rem;" {
h2 { "Quick Actions" } h2 { "Quick Actions" }
div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" { div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
@@ -102,6 +57,64 @@ pub async fn onchain_page(
} }
} }
} }
}
}
pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let address_result = state.node.inner.onchain_payment().new_address();
let content = match address_result {
Ok(address) => {
html! {
div class="card" {
h2 { "Bitcoin Address" }
div class="address-display" {
div class="address-container" {
span class="address-text" { (address.to_string()) }
}
}
}
div class="card" {
div style="display: flex; justify-content: space-between; gap: 1rem;" {
a href="/onchain" { button class="button-secondary" { "Back" } }
form method="post" action="/onchain/new-address" style="display: inline;" {
button class="button-primary" type="submit" { "Generate Another Address" }
}
}
}
}
}
Err(e) => {
html! {
(error_message(&format!("Failed to generate address: {e}")))
div class="card" {
a href="/onchain" { button class="button-primary" { "← Back to On-chain" } }
}
}
}
};
let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("New Address", content, is_running).into_string(),
))
}
pub async fn onchain_page(
State(state): State<AppState>,
query: Query<HashMap<String, String>>,
) -> Result<Html<String>, StatusCode> {
let balances = state.node.inner.list_balances();
let action = query
.get("action")
.map(|s| s.as_str())
.unwrap_or("overview");
let mut content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
// Quick Actions section - only show on overview
(quick_actions_section())
// On-chain Balance as metric cards // On-chain Balance as metric cards
div class="card" { div class="card" {
@@ -124,30 +137,6 @@ pub async fn onchain_page(
content = html! { content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
// Quick Actions section - individual cards
div class="card" style="margin-bottom: 2rem;" {
h2 { "Quick Actions" }
div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
// Receive Bitcoin Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
a href="/onchain?action=receive" style="text-decoration: none;" {
button class="button-outline" { "Receive Bitcoin" }
}
}
// Send Bitcoin Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
a href="/onchain?action=send" style="text-decoration: none;" {
button class="button-outline" { "Send Bitcoin" }
}
}
}
}
// Send form above balance // Send form above balance
(form_card( (form_card(
"Send On-chain Payment", "Send On-chain Payment",
@@ -193,30 +182,6 @@ pub async fn onchain_page(
content = html! { content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
// Quick Actions section - individual cards
div class="card" style="margin-bottom: 2rem;" {
h2 { "Quick Actions" }
div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
// Receive Bitcoin Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
a href="/onchain?action=receive" style="text-decoration: none;" {
button class="button-outline" { "Receive Bitcoin" }
}
}
// Send Bitcoin Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
a href="/onchain?action=send" style="text-decoration: none;" {
button class="button-outline" { "Send Bitcoin" }
}
}
}
}
// Generate address form above balance // Generate address form above balance
(form_card( (form_card(
"Generate New Address", "Generate New Address",
@@ -252,7 +217,10 @@ pub async fn onchain_page(
} }
} }
Ok(Html(layout("On-chain", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("On-chain", content, is_running).into_string(),
))
} }
pub async fn post_send_onchain( pub async fn post_send_onchain(
@@ -294,7 +262,7 @@ pub async fn onchain_confirm_page(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Send On-chain Error", content).into_string(), layout_with_status("Send On-chain Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -320,7 +288,7 @@ pub async fn onchain_confirm_page(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Send On-chain Error", content).into_string(), layout_with_status("Send On-chain Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -345,46 +313,46 @@ pub async fn onchain_confirm_page(
let content = html! { let content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Confirm On-chain Transaction" } h2 style="text-align: center; margin-bottom: 3rem;" { "Confirm On-chain Transaction" }
div class="card" style="border: 2px solid hsl(var(--primary)); background-color: hsl(var(--primary) / 0.05);" {
h2 { "⚠️ Transaction Confirmation" }
p style="color: hsl(var(--muted-foreground)); margin-bottom: 1.5rem;" {
"Please review the transaction details carefully before proceeding. This action cannot be undone."
}
}
(info_card(
"Transaction Details",
vec![
("Recipient Address", form.address.clone()),
("Amount to Send", if is_send_all {
format!("{} (All available funds)", format_sats_as_btc(amount_to_send))
} else {
format_sats_as_btc(amount_to_send)
}),
("Current Spendable Balance", format_sats_as_btc(spendable_balance)),
]
))
@if is_send_all { @if is_send_all {
div class="card" style="border: 1px solid hsl(32.6 75.4% 55.1%); background-color: hsl(32.6 75.4% 55.1% / 0.1);" { div class="card send-all-notice" {
h3 style="color: hsl(32.6 75.4% 55.1%);" { "Send All Notice" } h3 { "Send All Notice" }
p style="color: hsl(32.6 75.4% 55.1%);" { p {
"This transaction will send all available funds to the recipient address. " "This transaction will send all available funds to the recipient address. Network fees will be deducted from the total amount automatically."
"Network fees will be deducted from the total amount automatically."
} }
} }
} }
// Transaction Details Card
div class="card" { div class="card" {
div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" { h2 { "Transaction Details" }
div class="transaction-details" {
div class="detail-row" {
span class="detail-label" { "Recipient Address:" }
span class="detail-value" { (form.address.clone()) }
}
div class="detail-row" {
span class="detail-label" { "Amount to Send:" }
span class="detail-value-amount" {
(if is_send_all {
format!("{} (All available funds)", format_sats_as_btc(amount_to_send))
} else {
format_sats_as_btc(amount_to_send)
})
}
}
div class="detail-row" {
span class="detail-label" { "Current Spendable Balance:" }
span class="detail-value-amount" { (format_sats_as_btc(spendable_balance)) }
}
}
div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid hsl(var(--border));" {
a href="/onchain?action=send" { a href="/onchain?action=send" {
button type="button" class="button-secondary" { "Cancel" } button type="button" class="button-secondary" { "Cancel" }
} }
div style="display: flex; gap: 0.5rem;" { a href=(confirmation_url) {
a href=(confirmation_url) { button class="button-primary" {
button class="button-primary" { "Confirm"
"✓ Confirm & Send Transaction"
}
} }
} }
} }
@@ -394,7 +362,7 @@ pub async fn onchain_confirm_page(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Confirm Transaction", content).into_string(), layout_with_status("Confirm Transaction", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }
@@ -427,7 +395,7 @@ async fn execute_onchain_transaction(
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Send On-chain Error", content).into_string(), layout_with_status("Send On-chain Error", content, true).into_string(),
)) ))
.unwrap()); .unwrap());
} }
@@ -506,7 +474,7 @@ async fn execute_onchain_transaction(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from( .body(Body::from(
layout("Send On-chain Result", content).into_string(), layout_with_status("Send On-chain Result", content, true).into_string(),
)) ))
.unwrap()) .unwrap())
} }

View File

@@ -15,8 +15,8 @@ use serde::Deserialize;
use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming}; use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming};
use crate::web::handlers::AppState; use crate::web::handlers::AppState;
use crate::web::templates::{ use crate::web::templates::{
error_message, form_card, format_msats_as_btc, format_sats_as_btc, info_card, layout, error_message, form_card, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
payment_list_item, success_message, layout_with_status, payment_list_item, success_message,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -236,12 +236,13 @@ pub async fn payments_page(
} }
}; };
Ok(Html(layout("Payment History", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Payment History", content, is_running).into_string(),
))
} }
pub async fn send_payments_page( pub async fn send_payments_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
State(_state): State<AppState>,
) -> Result<Html<String>, StatusCode> {
let content = html! { let content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" } h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" }
div class="grid" { div class="grid" {
@@ -280,13 +281,12 @@ pub async fn send_payments_page(
)) ))
} }
div class="card" {
h3 { "Payment History" }
a href="/payments" { button { "View All Payments" } }
}
}; };
Ok(Html(layout("Send Payments", content).into_string())) let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Send Payments", content, is_running).into_string(),
))
} }
pub async fn post_pay_bolt11( pub async fn post_pay_bolt11(
@@ -306,7 +306,9 @@ pub async fn post_pay_bolt11(
return Ok(Response::builder() return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Error", content).into_string())) .body(Body::from(
layout_with_status("Payment Error", content, true).into_string(),
))
.unwrap()); .unwrap());
} }
}; };
@@ -348,7 +350,9 @@ pub async fn post_pay_bolt11(
return Ok(Response::builder() return Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR) .status(StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Error", content).into_string())) .body(Body::from(
layout_with_status("Payment Error", content, true).into_string(),
))
.unwrap()); .unwrap());
} }
}; };
@@ -433,7 +437,9 @@ pub async fn post_pay_bolt11(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Result", content).into_string())) .body(Body::from(
layout_with_status("Payment Result", content, true).into_string(),
))
.unwrap()) .unwrap())
} }
@@ -454,7 +460,9 @@ pub async fn post_pay_bolt12(
return Ok(Response::builder() return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Error", content).into_string())) .body(Body::from(
layout_with_status("Payment Error", content, true).into_string(),
))
.unwrap()); .unwrap());
} }
}; };
@@ -486,7 +494,9 @@ pub async fn post_pay_bolt12(
return Ok(Response::builder() return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST) .status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Error", content).into_string())) .body(Body::from(
layout_with_status("Payment Error", content, true).into_string(),
))
.unwrap()); .unwrap());
} }
}; };
@@ -518,7 +528,9 @@ pub async fn post_pay_bolt12(
return Ok(Response::builder() return Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR) .status(StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Error", content).into_string())) .body(Body::from(
layout_with_status("Payment Error", content, true).into_string(),
))
.unwrap()); .unwrap());
} }
}; };
@@ -610,6 +622,8 @@ pub async fn post_pay_bolt12(
Ok(Response::builder() Ok(Response::builder()
.header("content-type", "text/html") .header("content-type", "text/html")
.body(Body::from(layout("Payment Result", content).into_string())) .body(Body::from(
layout_with_status("Payment Result", content, true).into_string(),
))
.unwrap()) .unwrap())
} }

View File

@@ -1,6 +1,12 @@
use ldk_node::Node;
use maud::{html, Markup, DOCTYPE}; use maud::{html, Markup, DOCTYPE};
pub fn layout(title: &str, content: Markup) -> Markup { /// Helper function to check if the node is running
pub fn is_node_running(node: &Node) -> bool {
node.status().is_running
}
pub fn layout_with_status(title: &str, content: Markup, is_running: bool) -> Markup {
html! { html! {
(DOCTYPE) (DOCTYPE)
html lang="en" { html lang="en" {
@@ -252,12 +258,21 @@ pub fn layout(title: &str, content: Markup) -> Markup {
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
} }
.status-indicator.status-inactive {
background-color: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
.status-text { .status-text {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: #10b981; color: #10b981;
} }
.status-text.status-inactive {
color: #ef4444;
}
.node-title { .node-title {
font-size: 1.875rem; font-size: 1.875rem;
font-weight: 600; font-weight: 600;
@@ -281,38 +296,104 @@ pub fn layout(title: &str, content: Markup) -> Markup {
/* Responsive header */ /* Responsive header */
@media (max-width: 768px) { @media (max-width: 768px) {
header {
height: 180px; /* Slightly taller for better mobile layout */
padding: 1rem 0;
}
header .container {
padding: 0 1rem;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.header-content { .header-content {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
text-align: center; text-align: center;
width: 100%;
justify-content: center;
} }
.header-left { .header-left {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
align-items: center;
gap: 0.75rem;
}
.header-avatar {
width: 64px;
height: 64px;
padding: 0.5rem;
}
.header-avatar-image {
width: 40px;
height: 40px;
} }
.node-title { .node-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
.node-subtitle {
font-size: 0.8125rem;
text-align: center;
}
.node-status {
justify-content: center;
}
} }
@media (max-width: 480px) {
header {
height: 160px;
}
.header-avatar {
width: 56px;
height: 56px;
padding: 0.375rem;
}
.header-avatar-image {
width: 36px;
height: 36px;
}
.node-title {
font-size: 1.25rem;
}
.node-subtitle {
font-size: 0.75rem;
}
}
/* Dark mode navigation styles */
@media (prefers-color-scheme: dark) {
nav a { nav a {
color: var(--text-muted) !important; color: var(--text-muted) !important;
} }
nav a:hover { nav a:hover {
color: var(--text-secondary) !important; color: var(--text-secondary) !important;
background-color: rgba(255, 255, 255, 0.05) !important; background-color: rgba(255, 255, 255, 0.08) !important;
transform: translateY(-1px) !important;
} }
nav a.active { nav a.active {
color: var(--text-primary) !important; color: var(--text-primary) !important;
background-color: rgba(255, 255, 255, 0.08) !important; background-color: rgba(255, 255, 255, 0.1) !important;
} }
nav a.active:hover { nav a.active:hover {
background-color: rgba(255, 255, 255, 0.1) !important; background-color: rgba(255, 255, 255, 0.12) !important;
transform: translateY(-1px) !important;
} }
} }
@@ -409,23 +490,6 @@ pub fn layout(title: &str, content: Markup) -> Markup {
line-height: 1.6; line-height: 1.6;
} }
@media (max-width: 768px) {
header {
height: 150px; /* Smaller height on mobile */
}
header .container {
padding: 0 1rem;
}
h1 {
font-size: 2.25rem;
}
.subtitle {
font-size: 1.1rem;
}
}
/* Card fade-in animation */ /* Card fade-in animation */
@keyframes fade-in { @keyframes fade-in {
@@ -491,6 +555,15 @@ pub fn layout(title: &str, content: Markup) -> Markup {
background-color: hsl(var(--muted)); background-color: hsl(var(--muted));
} }
/* Light mode navigation hover states */
@media (prefers-color-scheme: light) {
nav a:hover {
color: hsl(var(--foreground));
background-color: hsl(var(--muted) / 0.8);
transform: translateY(-1px);
}
}
nav a.active { nav a.active {
color: hsl(var(--primary-foreground)); color: hsl(var(--primary-foreground));
background-color: hsl(var(--primary)); background-color: hsl(var(--primary));
@@ -587,6 +660,20 @@ pub fn layout(title: &str, content: Markup) -> Markup {
width: 100%; width: 100%;
} }
/* Dark mode input field improvements */
@media (prefers-color-scheme: dark) {
input, textarea, select {
background-color: hsl(0 0% 8%);
border: 1px solid hsl(0 0% 20%);
color: hsl(var(--foreground));
}
input:focus, textarea:focus, select:focus {
background-color: hsl(0 0% 10%);
border-color: hsl(var(--ring));
}
}
input:focus, textarea:focus, select:focus { input:focus, textarea:focus, select:focus {
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: 2px; outline-offset: 2px;
@@ -599,6 +686,74 @@ pub fn layout(title: &str, content: Markup) -> Markup {
opacity: 0.5; opacity: 0.5;
} }
/* Subtle pagination dropdown styling */
.per-page-selector {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0 0 0;
padding: 0;
background-color: transparent;
border: none;
border-radius: 0;
font-size: 0.875rem;
}
.per-page-selector label {
color: hsl(var(--muted-foreground));
font-weight: 500;
}
.per-page-selector select {
background-color: transparent;
border: 1px solid hsl(var(--muted));
border-radius: calc(var(--radius) - 2px);
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
min-width: 50px;
cursor: pointer;
transition: all 0.2s ease;
flex: none;
width: auto;
}
.per-page-selector select:hover {
border-color: hsl(var(--ring));
background-color: hsl(var(--muted) / 0.5);
}
.per-page-selector select:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: hsl(var(--ring));
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
}
.per-page-selector span {
color: hsl(var(--muted-foreground));
font-weight: 500;
}
/* Form actions layout */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
}
.form-actions .button-secondary {
order: 1;
}
.form-actions .button-primary {
order: 2;
}
button { button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1082,6 +1237,14 @@ pub fn layout(title: &str, content: Markup) -> Markup {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
/* Dark mode payment card improvements - match other cards */
@media (prefers-color-scheme: dark) {
.payment-item {
background-color: rgba(255, 255, 255, 0.03) !important;
border: none !important;
}
}
.payment-header { .payment-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1266,6 +1429,39 @@ pub fn layout(title: &str, content: Markup) -> Markup {
border: 1px solid hsl(32 95% 44% / 0.2); border: 1px solid hsl(32 95% 44% / 0.2);
} }
/* Dark mode payment type badge improvements */
@media (prefers-color-scheme: dark) {
.payment-type-onchain {
background-color: hsl(32 95% 60% / 0.15);
color: hsl(32 95% 70%);
border: 1px solid hsl(32 95% 60% / 0.3);
}
.payment-type-bolt11 {
background-color: hsl(217 91% 70% / 0.15);
color: hsl(217 91% 80%);
border: 1px solid hsl(217 91% 70% / 0.3);
}
.payment-type-bolt12 {
background-color: hsl(262 83% 70% / 0.15);
color: hsl(262 83% 80%);
border: 1px solid hsl(262 83% 70% / 0.3);
}
.payment-type-spontaneous {
background-color: hsl(142.1 70.6% 60% / 0.15);
color: hsl(142.1 70.6% 75%);
border: 1px solid hsl(142.1 70.6% 60% / 0.3);
}
.payment-type-bolt11-jit {
background-color: hsl(199 89% 65% / 0.15);
color: hsl(199 89% 80%);
border: 1px solid hsl(199 89% 65% / 0.3);
}
}
.payment-type-spontaneous { .payment-type-spontaneous {
background-color: hsl(142.1 70.6% 45.3% / 0.1); background-color: hsl(142.1 70.6% 45.3% / 0.1);
color: hsl(142.1 70.6% 45.3%); color: hsl(142.1 70.6% 45.3%);
@@ -1635,6 +1831,147 @@ pub fn layout(title: &str, content: Markup) -> Markup {
fill: rgba(156, 163, 175, 0.2); fill: rgba(156, 163, 175, 0.2);
} }
} }
/* Address display styling */
.address-display {
margin: 1.5rem 0;
}
.address-container {
padding: 1rem 0;
}
.address-text {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace;
font-size: 1.25rem;
font-weight: 500;
color: hsl(var(--foreground));
word-break: break-all;
overflow-wrap: break-word;
hyphens: auto;
flex: 1;
min-width: 0;
line-height: 1.4;
background-color: transparent;
border: none;
padding: 0;
}
/* Dark mode address styling */
@media (prefers-color-scheme: dark) {
.address-text {
color: var(--text-primary) !important;
}
}
/* Responsive address display */
@media (max-width: 640px) {
.address-text {
font-size: 1.125rem;
text-align: center;
}
}
/* Transaction confirmation styling */
.transaction-details {
margin-top: 1rem;
}
.transaction-details .detail-row {
display: flex;
align-items: baseline;
margin-bottom: 1rem;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid hsl(var(--border));
}
.transaction-details .detail-row:last-child {
border-bottom: none;
margin-bottom: 0;
}
.transaction-details .detail-label {
font-weight: 500;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
min-width: 180px;
flex-shrink: 0;
}
.transaction-details .detail-value {
color: hsl(var(--foreground));
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace;
font-size: 0.875rem;
word-break: break-all;
flex: 1;
min-width: 0;
}
.transaction-details .detail-value-amount {
color: hsl(var(--foreground));
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace;
font-size: 1rem;
font-weight: 600;
flex: 1;
min-width: 0;
}
.send-all-notice {
border: 1px solid hsl(32.6 75.4% 55.1%);
background-color: hsl(32.6 75.4% 55.1% / 0.1);
}
.send-all-notice h3 {
color: hsl(32.6 75.4% 55.1%);
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.send-all-notice p {
color: hsl(32.6 75.4% 55.1%);
font-size: 0.875rem;
line-height: 1.4;
margin: 0;
}
/* Dark mode transaction styling */
@media (prefers-color-scheme: dark) {
.transaction-details .detail-label {
color: var(--text-muted) !important;
}
.transaction-details .detail-value,
.transaction-details .detail-value-amount {
color: var(--text-primary) !important;
}
.send-all-notice {
background-color: hsl(32.6 75.4% 55.1% / 0.15) !important;
border-color: hsl(32.6 75.4% 55.1% / 0.3) !important;
}
}
/* Responsive transaction details */
@media (max-width: 640px) {
.transaction-details .detail-row {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.transaction-details .detail-label {
min-width: auto;
font-size: 0.8125rem;
}
.transaction-details .detail-value,
.transaction-details .detail-value-amount {
font-size: 0.875rem;
}
}
" "
} }
} }
@@ -1648,8 +1985,13 @@ pub fn layout(title: &str, content: Markup) -> Markup {
} }
div class="node-info" { div class="node-info" {
div class="node-status" { div class="node-status" {
span class="status-indicator status-running" {} @if is_running {
span class="status-text" { "Running" } span class="status-indicator" {}
span class="status-text" { "Running" }
} @else {
span class="status-indicator status-inactive" {}
span class="status-text status-inactive" { "Inactive" }
}
} }
h1 class="node-title" { "CDK LDK Node" } h1 class="node-title" { "CDK LDK Node" }
span class="node-subtitle" { "Cashu Mint & Lightning Network Node Management" } span class="node-subtitle" { "Cashu Mint & Lightning Network Node Management" }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 144 KiB