Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1df8903f87 |
@@ -80,19 +80,11 @@ pub struct Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Detect primary host IP (first non-loopback IPv4)
|
/// Detect primary host IP (default-route interface, not `hostname -I` order)
|
||||||
async fn detect_host_ip() -> Result<String> {
|
async fn detect_host_ip() -> Result<String> {
|
||||||
let output = tokio::process::Command::new("hostname")
|
Ok(crate::host_ip::primary_host_ipv4()
|
||||||
.args(["-I"])
|
|
||||||
.output()
|
|
||||||
.await
|
.await
|
||||||
.context("Failed to run hostname -I")?;
|
.unwrap_or_else(|| "127.0.0.1".to_string()))
|
||||||
let s = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let ip = s
|
|
||||||
.split_whitespace()
|
|
||||||
.find(|s| !s.starts_with("127.") && s.contains('.'))
|
|
||||||
.unwrap_or("127.0.0.1");
|
|
||||||
Ok(ip.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load() -> Result<Self> {
|
pub async fn load() -> Result<Self> {
|
||||||
|
|||||||
@@ -696,22 +696,11 @@ async fn netbird_configured_launch_url() -> Option<String> {
|
|||||||
PodmanClient::lan_address_for("netbird")
|
PodmanClient::lan_address_for("netbird")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First address from `hostname -I` — the node's primary host IP. Mirrors the
|
/// The node's primary host IP. Mirrors the orchestrator's `detect_host_ip`
|
||||||
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the
|
/// so launch URLs match the cert/config the orchestrator renders for
|
||||||
/// orchestrator renders for `{{HOST_IP}}`.
|
/// `{{HOST_IP}}`.
|
||||||
async fn first_host_ip() -> Option<String> {
|
async fn first_host_ip() -> Option<String> {
|
||||||
let out = tokio::process::Command::new("hostname")
|
crate::host_ip::primary_host_ipv4().await
|
||||||
.arg("-I")
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
String::from_utf8_lossy(&out.stdout)
|
|
||||||
.split_whitespace()
|
|
||||||
.next()
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||||
|
|||||||
@@ -3087,16 +3087,7 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn detect_host_ip() -> Option<String> {
|
async fn detect_host_ip() -> Option<String> {
|
||||||
let output = tokio::process::Command::new("hostname")
|
crate::host_ip::primary_host_ipv4().await
|
||||||
.arg("-I")
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
stdout.split_whitespace().next().map(|s| s.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn detect_host_mdns() -> String {
|
async fn detect_host_mdns() -> String {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! Primary host LAN IPv4 detection.
|
||||||
|
//!
|
||||||
|
//! `hostname -I` lists addresses in interface-creation order, so once a VPN
|
||||||
|
//! or bridge interface exists (NetBird's WireGuard tunnel, br-tollgate, …)
|
||||||
|
//! its address can sort ahead of the real NIC — a fresh-ISO node handed out
|
||||||
|
//! `https://10.44.0.1:8087` as NetBird's launch URL instead of the LAN IP.
|
||||||
|
//! The main routing table's default route names the physical uplink even when
|
||||||
|
//! a VPN is active (NetBird/Tailscale steer traffic via policy-routing rules
|
||||||
|
//! in separate tables, not by replacing the main-table default), so that is
|
||||||
|
//! the authoritative source, with `hostname -I` kept only as the last resort
|
||||||
|
//! for hosts with no default route at all.
|
||||||
|
|
||||||
|
/// The node's primary LAN IPv4, as a string.
|
||||||
|
///
|
||||||
|
/// Resolution order:
|
||||||
|
/// 1. `src`/`dev` of the main-table default route (`ip -4 route show default`)
|
||||||
|
/// 2. source address of a connected UDP socket (never transmits)
|
||||||
|
/// 3. first non-loopback IPv4 from `hostname -I` (legacy behaviour)
|
||||||
|
pub(crate) async fn primary_host_ipv4() -> Option<String> {
|
||||||
|
if let Some(ip) = default_route_ip().await {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
if let Some(ip) = udp_route_ip() {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
hostname_i_ip().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn default_route_ip() -> Option<String> {
|
||||||
|
let out = tokio::process::Command::new("ip")
|
||||||
|
.args(["-4", "route", "show", "default"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let route = String::from_utf8_lossy(&out.stdout);
|
||||||
|
if let Some(ip) = parse_route_src(&route) {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
// No `src` hint on the route — resolve the device's global address.
|
||||||
|
let dev = parse_route_dev(&route)?;
|
||||||
|
let out = tokio::process::Command::new("ip")
|
||||||
|
.args(["-4", "-o", "addr", "show", "dev", &dev, "scope", "global"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
parse_addr_inet(&String::from_utf8_lossy(&out.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_route_src(route: &str) -> Option<String> {
|
||||||
|
field_after(route.lines().next()?, "src")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_route_dev(route: &str) -> Option<String> {
|
||||||
|
field_after(route.lines().next()?, "dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_after(line: &str, key: &str) -> Option<String> {
|
||||||
|
let mut words = line.split_whitespace();
|
||||||
|
while let Some(w) = words.next() {
|
||||||
|
if w == key {
|
||||||
|
return words.next().map(ToOwned::to_owned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_addr_inet(out: &str) -> Option<String> {
|
||||||
|
let cidr = field_after(out.lines().next()?, "inet")?;
|
||||||
|
Some(cidr.split('/').next().unwrap_or(&cidr).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connected UDP socket's local address is the source IP the kernel would
|
||||||
|
/// use to reach the peer; nothing is sent. Can still land on a tunnel IP when
|
||||||
|
/// a VPN policy-routes all traffic, hence only a fallback.
|
||||||
|
fn udp_route_ip() -> Option<String> {
|
||||||
|
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||||
|
sock.connect("8.8.8.8:80").ok()?;
|
||||||
|
match sock.local_addr().ok()?.ip() {
|
||||||
|
std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_unspecified() => {
|
||||||
|
Some(v4.to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hostname_i_ip() -> Option<String> {
|
||||||
|
let out = tokio::process::Command::new("hostname")
|
||||||
|
.arg("-I")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.find(|s| !s.starts_with("127.") && s.contains('.'))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_src_wins() {
|
||||||
|
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600";
|
||||||
|
assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_dev_without_src() {
|
||||||
|
let route = "default via 192.168.1.1 dev enp0s31f6 proto static";
|
||||||
|
assert_eq!(parse_route_src(route), None);
|
||||||
|
assert_eq!(parse_route_dev(route).as_deref(), Some("enp0s31f6"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addr_inet_strips_prefix() {
|
||||||
|
let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
|
||||||
|
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_route_table() {
|
||||||
|
assert_eq!(parse_route_src(""), None);
|
||||||
|
assert_eq!(parse_route_dev(""), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ mod electrs_status;
|
|||||||
mod federation;
|
mod federation;
|
||||||
mod fips;
|
mod fips;
|
||||||
mod health_monitor;
|
mod health_monitor;
|
||||||
|
mod host_ip;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod identity_manager;
|
mod identity_manager;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
<meta http-equiv="Pragma" content="no-cache">
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
<title id="pageTitle">Bitcoin Node - Archipelago</title>
|
<title id="pageTitle">Bitcoin Node - Archipelago</title>
|
||||||
<link rel="stylesheet" href="/tailwind.css">
|
<!-- Relative: the shell is served at / in the container but under
|
||||||
|
/app/bitcoin-ui/ in the public demo — absolute paths 404 there. -->
|
||||||
|
<link rel="stylesheet" href="tailwind.css">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -339,7 +341,7 @@
|
|||||||
<div class="logo-gradient-border">
|
<div class="logo-gradient-border">
|
||||||
<img
|
<img
|
||||||
id="implLogo"
|
id="implLogo"
|
||||||
src="/assets/img/app-icons/bitcoin-knots.webp"
|
src="assets/img/app-icons/bitcoin-knots.webp"
|
||||||
alt="Bitcoin Node"
|
alt="Bitcoin Node"
|
||||||
class="w-16 h-16"
|
class="w-16 h-16"
|
||||||
style="object-fit: contain;"
|
style="object-fit: contain;"
|
||||||
@@ -984,8 +986,8 @@
|
|||||||
? 'Enhanced Bitcoin node implementation'
|
? 'Enhanced Bitcoin node implementation'
|
||||||
: 'Reference Bitcoin node implementation';
|
: 'Reference Bitcoin node implementation';
|
||||||
const icon = isKnots
|
const icon = isKnots
|
||||||
? '/assets/img/app-icons/bitcoin-knots.webp'
|
? 'assets/img/app-icons/bitcoin-knots.webp'
|
||||||
: '/assets/img/app-icons/bitcoin-core.svg';
|
: 'assets/img/app-icons/bitcoin-core.svg';
|
||||||
const pageTitle = document.getElementById('pageTitle');
|
const pageTitle = document.getElementById('pageTitle');
|
||||||
const implName = document.getElementById('implName');
|
const implName = document.getElementById('implName');
|
||||||
const implTagline = document.getElementById('implTagline');
|
const implTagline = document.getElementById('implTagline');
|
||||||
|
|||||||
@@ -386,7 +386,7 @@
|
|||||||
<section class="glass-card">
|
<section class="glass-card">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo-gradient-border">
|
<div class="logo-gradient-border">
|
||||||
<img src="/assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
|
<img src="assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
|
||||||
</div>
|
</div>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<h1>Fedimint Guardian</h1>
|
<h1>Fedimint Guardian</h1>
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Proxy FileBrowser API to mock backend (demo mode)
|
# Proxy FileBrowser API to mock backend (demo mode)
|
||||||
location /app/filebrowser/ {
|
# ^~ on every /app/ prefix: the .css/.js/.img cache regex below must
|
||||||
|
# never swallow app-shell assets (they live on the backend, not in the
|
||||||
|
# web root — without ^~ nginx prefers the regex and 404s them).
|
||||||
|
location ^~ /app/filebrowser/ {
|
||||||
client_max_body_size 10G;
|
client_max_body_size 10G;
|
||||||
proxy_pass http://neode-backend:5959;
|
proxy_pass http://neode-backend:5959;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -103,7 +106,7 @@ http {
|
|||||||
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
|
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
|
||||||
# and rewrite its absolute asset paths (/assets, /, src, href) to the
|
# and rewrite its absolute asset paths (/assets, /, src, href) to the
|
||||||
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
|
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
|
||||||
location /app/indeedhub/ {
|
location ^~ /app/indeedhub/ {
|
||||||
proxy_pass https://indee.tx1138.com/;
|
proxy_pass https://indee.tx1138.com/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host indee.tx1138.com;
|
proxy_set_header Host indee.tx1138.com;
|
||||||
@@ -129,7 +132,7 @@ http {
|
|||||||
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
||||||
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
||||||
# generic "Not available in the demo" notice for the rest.
|
# generic "Not available in the demo" notice for the rest.
|
||||||
location /app/ {
|
location ^~ /app/ {
|
||||||
proxy_pass http://neode-backend:5959;
|
proxy_pass http://neode-backend:5959;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -1249,7 +1249,8 @@ const DOCKER_UI = path.join(__dirname, '..', 'docker')
|
|||||||
for (const [prefixes, dir] of [
|
for (const [prefixes, dir] of [
|
||||||
[['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'],
|
[['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'],
|
||||||
[['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'],
|
[['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'],
|
||||||
[['/app/lnd', '/app/lnd-ui', '/app/archy-lnd-ui', '/app/thunderhub'], 'lnd-ui'],
|
// lnd deliberately NOT here: the real lnd-ui shell reads poorly in the demo
|
||||||
|
// iframe, so /app/lnd/ gets a DEMO_APP_PAGES placeholder dashboard instead.
|
||||||
[['/app/fedimint', '/app/fedimintd'], 'fedimint-ui'],
|
[['/app/fedimint', '/app/fedimintd'], 'fedimint-ui'],
|
||||||
]) {
|
]) {
|
||||||
for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir)))
|
for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir)))
|
||||||
@@ -2195,6 +2196,15 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
case 'content.download-peer-paid':
|
case 'content.download-peer-paid':
|
||||||
case 'content.download-peer-invoice':
|
case 'content.download-peer-invoice':
|
||||||
case 'content.download-peer-onchain': {
|
case 'content.download-peer-onchain': {
|
||||||
|
// Deduct the price from the chosen rail so demo balances react.
|
||||||
|
const paid = params?.price_sats || 0
|
||||||
|
if (paid > 0 && method === 'content.download-peer-paid') {
|
||||||
|
if (params?.method === 'ark') walletState.ark_sats = Math.max(0, walletState.ark_sats - paid)
|
||||||
|
else if (params?.method === 'fedimint') {
|
||||||
|
const fed = (mockState.federations || [])[0]
|
||||||
|
if (fed) fed.balance_sats = Math.max(0, (fed.balance_sats || 0) - paid)
|
||||||
|
} else walletState.ecash_sats = Math.max(0, walletState.ecash_sats - paid)
|
||||||
|
}
|
||||||
const filename = params?.filename || 'demo-content'
|
const filename = params?.filename || 'demo-content'
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
|
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
|
||||||
@@ -2418,20 +2428,34 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'network.dns-status': {
|
case 'network.dns-status': {
|
||||||
|
const dns = mockState.dns || { provider: 'system', servers: ['1.1.1.1', '9.9.9.9'], doh_enabled: false }
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
provider: 'system',
|
provider: dns.provider,
|
||||||
servers: ['1.1.1.1', '9.9.9.9'],
|
servers: dns.servers,
|
||||||
doh_enabled: false,
|
doh_enabled: dns.doh_enabled,
|
||||||
doh_url: null,
|
doh_url: null,
|
||||||
resolv_conf_servers: ['1.1.1.1', '9.9.9.9'],
|
resolv_conf_servers: dns.servers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'network.configure-dns': {
|
case 'network.configure-dns': {
|
||||||
console.log(`[Network] DNS configured: ${params?.provider}`)
|
const dnsProviders = {
|
||||||
return res.json({ result: { success: true } })
|
system: ['192.168.4.1'],
|
||||||
|
cloudflare: ['1.1.1.1', '1.0.0.1'],
|
||||||
|
google: ['8.8.8.8', '8.8.4.4'],
|
||||||
|
quad9: ['9.9.9.9', '149.112.112.112'],
|
||||||
|
mullvad: ['194.242.2.2'],
|
||||||
|
}
|
||||||
|
const provider = params?.provider || 'system'
|
||||||
|
const servers = provider === 'custom'
|
||||||
|
? (Array.isArray(params?.servers) ? params.servers : [])
|
||||||
|
: (dnsProviders[provider] || dnsProviders.system)
|
||||||
|
const doh_enabled = ['cloudflare', 'google', 'quad9', 'mullvad'].includes(provider)
|
||||||
|
mockState.dns = { provider, servers, doh_enabled }
|
||||||
|
console.log(`[Network] DNS configured: ${provider} → ${servers.join(', ')}`)
|
||||||
|
return res.json({ result: { provider, servers, doh_enabled } })
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'network.accept-request': {
|
case 'network.accept-request': {
|
||||||
@@ -3315,10 +3339,15 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
// Wallet / Ecash (Fedimint)
|
// Wallet / Ecash (Fedimint)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
case 'wallet.ecash-balance': {
|
case 'wallet.ecash-balance': {
|
||||||
|
const fedSats = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
balance_sats: walletState.ecash_sats,
|
balance_sats: walletState.ecash_sats,
|
||||||
balance_msat: walletState.ecash_sats * 1000,
|
balance_msat: walletState.ecash_sats * 1000,
|
||||||
|
cashu_sats: walletState.ecash_sats,
|
||||||
|
fedimint_sats: fedSats,
|
||||||
|
ark_sats: walletState.ark_sats,
|
||||||
|
total_sats: walletState.ecash_sats + fedSats + walletState.ark_sats,
|
||||||
token_count: walletState.ecash_tokens,
|
token_count: walletState.ecash_tokens,
|
||||||
federations: [
|
federations: [
|
||||||
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
|
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
|
||||||
@@ -4741,6 +4770,34 @@ app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.for
|
|||||||
// something plausible in the in-app iframe. Registered before the generic
|
// something plausible in the in-app iframe. Registered before the generic
|
||||||
// /app/:id notice handler so these win.
|
// /app/:id notice handler so these win.
|
||||||
const DEMO_APP_PAGES = {
|
const DEMO_APP_PAGES = {
|
||||||
|
// Placeholder LND dashboard — the real lnd-ui shell reads poorly inside the
|
||||||
|
// demo iframe. Numbers stay consistent with the /proxy/lnd/v1/* mocks.
|
||||||
|
lnd: () => demoAppShell('Lightning Network Daemon', 'archipelago-lnd · v0.18.3-beta · signet', '/assets/img/app-icons/lnd.png', `
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">Running · synced</span></div></div>
|
||||||
|
<div class="card"><div class="k">On-chain</div><div class="v">2,450,000 sats</div></div>
|
||||||
|
<div class="card"><div class="k">Lightning (local)</div><div class="v">8,250,000 sats</div></div>
|
||||||
|
<div class="card"><div class="k">Inbound capacity</div><div class="v">11,750,000 sats</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="k">Channels</div><div class="v">4 active</div></div>
|
||||||
|
<div class="card"><div class="k">Peers</div><div class="v">11</div></div>
|
||||||
|
<div class="card"><div class="k">Block height</div><div class="v">902,418</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k" style="margin-bottom:6px">Channels</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>Peer</th><th>Capacity</th><th>Local / Remote</th><th style="width:30%">Balance</th></tr>
|
||||||
|
<tr><td>ACINQ</td><td>5,000,000</td><td>2,450,000 / 2,550,000</td><td><div class="bar"><i style="width:49%"></i></div></td></tr>
|
||||||
|
<tr><td>Voltage</td><td>10,000,000</td><td>4,500,000 / 5,500,000</td><td><div class="bar"><i style="width:45%"></i></div></td></tr>
|
||||||
|
<tr><td>Kraken</td><td>3,000,000</td><td>1,800,000 / 1,200,000</td><td><div class="bar"><i style="width:60%"></i></div></td></tr>
|
||||||
|
<tr><td>Wallet of Satoshi</td><td>2,000,000</td><td>1,200,000 / 800,000</td><td><div class="bar"><i style="width:60%"></i></div></td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:14px">
|
||||||
|
<div class="k" style="margin-bottom:6px">Node URI</div>
|
||||||
|
<div class="v mono">02c9f0a1…e47b@lnd7f3a2c9d1b4e8f6.onion:9735</div>
|
||||||
|
</div>`),
|
||||||
'btcpay-server': () => demoAppShell('BTCPay Server', 'Self-hosted payment processor · signet', '/assets/img/app-icons/btcpay-server.png', `
|
'btcpay-server': () => demoAppShell('BTCPay Server', 'Self-hosted payment processor · signet', '/assets/img/app-icons/btcpay-server.png', `
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div>
|
<div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
|
v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] as const)"
|
||||||
:key="m"
|
:key="m"
|
||||||
@click="receiveMethod = m"
|
@click="receiveMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
|
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : m === 'ecash' ? t('receiveBitcoin.ecash') : 'Ark' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightning -->
|
<!-- Lightning -->
|
||||||
@@ -43,6 +43,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Ark -->
|
||||||
|
<div v-if="receiveMethod === 'ark'">
|
||||||
|
<div v-if="arkAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||||
|
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||||
|
<p class="text-white/50 text-xs mb-2">Your Ark address</p>
|
||||||
|
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
|
||||||
|
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-3 text-center">
|
||||||
|
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ecash -->
|
<!-- Ecash -->
|
||||||
<div v-if="receiveMethod === 'ecash'">
|
<div v-if="receiveMethod === 'ecash'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -57,7 +70,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
|
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
@@ -75,15 +88,17 @@ const { t } = useI18n()
|
|||||||
defineProps<{ show: boolean }>()
|
defineProps<{ show: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; received: [] }>()
|
const emit = defineEmits<{ close: []; received: [] }>()
|
||||||
|
|
||||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('onchain')
|
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
|
||||||
const invoiceAmount = ref<number>(0)
|
const invoiceAmount = ref<number>(0)
|
||||||
const invoiceMemo = ref('')
|
const invoiceMemo = ref('')
|
||||||
const invoiceResult = ref('')
|
const invoiceResult = ref('')
|
||||||
const onchainAddress = ref('')
|
const onchainAddress = ref('')
|
||||||
|
const arkAddress = ref('')
|
||||||
const ecashToken = ref('')
|
const ecashToken = ref('')
|
||||||
const ecashResult = ref('')
|
const ecashResult = ref('')
|
||||||
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -102,6 +117,7 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
|
|||||||
function close() {
|
function close() {
|
||||||
invoiceResult.value = ''
|
invoiceResult.value = ''
|
||||||
onchainAddress.value = ''
|
onchainAddress.value = ''
|
||||||
|
arkAddress.value = ''
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
ecashResult.value = ''
|
ecashResult.value = ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -131,6 +147,11 @@ async function receive() {
|
|||||||
}
|
}
|
||||||
onchainAddress.value = res.address
|
onchainAddress.value = res.address
|
||||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||||
|
} else if (receiveMethod.value === 'ark') {
|
||||||
|
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
|
||||||
|
if (!res.address) throw new Error('barkd did not return an Ark address')
|
||||||
|
arkAddress.value = res.address
|
||||||
|
nextTick(() => renderQr(res.address, arkQrCanvas.value))
|
||||||
} else {
|
} else {
|
||||||
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||||
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
|
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
|
||||||
:key="m"
|
:key="m"
|
||||||
@click="sendMethod = m"
|
@click="sendMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
|
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
|
|
||||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">
|
<label class="text-white/60 text-sm block mb-1">
|
||||||
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
|
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
|
||||||
</label>
|
</label>
|
||||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
<div v-if="resultHash" class="mb-3 alert-success">
|
<div v-if="resultHash" class="mb-3 alert-success">
|
||||||
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="resultArk" class="mb-3 alert-success">
|
||||||
|
<p class="text-xs">{{ resultArk }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||||
|
|
||||||
@@ -62,13 +65,14 @@ const { t } = useI18n()
|
|||||||
const props = defineProps<{ show: boolean }>()
|
const props = defineProps<{ show: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; sent: [] }>()
|
const emit = defineEmits<{ close: []; sent: [] }>()
|
||||||
|
|
||||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
|
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
||||||
const amount = ref<number>(0)
|
const amount = ref<number>(0)
|
||||||
const dest = ref('')
|
const dest = ref('')
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const resultTxid = ref('')
|
const resultTxid = ref('')
|
||||||
const resultHash = ref('')
|
const resultHash = ref('')
|
||||||
|
const resultArk = ref('')
|
||||||
const ecashToken = ref('')
|
const ecashToken = ref('')
|
||||||
|
|
||||||
const effectiveMethod = computed(() => {
|
const effectiveMethod = computed(() => {
|
||||||
@@ -84,6 +88,7 @@ function close() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
resultTxid.value = ''
|
resultTxid.value = ''
|
||||||
resultHash.value = ''
|
resultHash.value = ''
|
||||||
|
resultArk.value = ''
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -99,10 +104,20 @@ async function send() {
|
|||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
resultTxid.value = ''
|
resultTxid.value = ''
|
||||||
resultHash.value = ''
|
resultHash.value = ''
|
||||||
|
resultArk.value = ''
|
||||||
|
|
||||||
const method = effectiveMethod.value
|
const method = effectiveMethod.value
|
||||||
try {
|
try {
|
||||||
if (method === 'ecash') {
|
if (method === 'ark') {
|
||||||
|
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
||||||
|
await rpcClient.call<{ sent: boolean }>({
|
||||||
|
method: 'wallet.ark-send',
|
||||||
|
params: { destination: dest.value.trim(), amount_sats: amount.value },
|
||||||
|
// Ark sends can wait on round participation.
|
||||||
|
timeout: 130000,
|
||||||
|
})
|
||||||
|
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
|
||||||
|
} else if (method === 'ecash') {
|
||||||
const res = await rpcClient.call<{ token: string }>({
|
const res = await rpcClient.call<{ token: string }>({
|
||||||
method: 'wallet.ecash-send',
|
method: 'wallet.ecash-send',
|
||||||
params: { amount_sats: amount.value },
|
params: { amount_sats: amount.value },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
|
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh — see
|
||||||
|
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
|
||||||
|
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
|
||||||
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
||||||
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
||||||
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
||||||
|
|||||||
@@ -1,10 +1,163 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="apps-view pb-6">
|
||||||
|
|
||||||
<!-- Content Type Cards -->
|
<!-- Nav header — tabs + categories + search, matching the Apps layout -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="mb-4">
|
||||||
|
<!-- Desktop: page tabs + category tabs + search on one row -->
|
||||||
|
<div class="app-header-desktop items-center gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="mode-switcher hidden md:inline-flex">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.id"
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>{{ tab.name }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||||
|
<button
|
||||||
|
v-for="category in CATEGORIES"
|
||||||
|
:key="category.id"
|
||||||
|
@click="selectedCategory = category.id"
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||||
|
>{{ category.name }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-search-wrap flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search your files and peer files…"
|
||||||
|
aria-label="Search files"
|
||||||
|
data-controller-no-submit
|
||||||
|
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: pill strips (NOT the fixed top tabs — those stay page-level
|
||||||
|
navigation) + search below. .mobile-category-strip also opts these
|
||||||
|
rows out of the dashboard's swipe-to-switch-page gesture. -->
|
||||||
|
<div class="app-header-mobile mb-4">
|
||||||
|
<div class="mobile-category-strip mb-2" aria-label="Cloud tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
class="mobile-category-pill"
|
||||||
|
:class="{ 'mobile-category-pill-active': activeTab === tab.id }"
|
||||||
|
type="button"
|
||||||
|
>{{ tab.name }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-category-strip mb-3" aria-label="File categories">
|
||||||
|
<button
|
||||||
|
v-for="category in CATEGORIES"
|
||||||
|
:key="category.id"
|
||||||
|
@click="selectedCategory = category.id"
|
||||||
|
class="mobile-category-pill"
|
||||||
|
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||||
|
type="button"
|
||||||
|
>{{ category.name }}</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search your files and peer files…"
|
||||||
|
aria-label="Search files"
|
||||||
|
data-controller-no-submit
|
||||||
|
class="app-header-search w-full min-w-0 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═════════════ Search results (any tab, when a query is active) ═════════════ -->
|
||||||
|
<div v-if="searchActive">
|
||||||
|
<div v-if="searching" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||||
|
<svg class="animate-spin h-4 w-4" 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>
|
||||||
|
Searching your files and peers…
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="filteredSearchResults.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||||
|
No files match “{{ searchQuery }}”.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="r in filteredSearchResults"
|
||||||
|
:key="r.key"
|
||||||
|
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
|
||||||
|
@click="openSearchResult(r)"
|
||||||
|
>
|
||||||
|
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(r.category).iconBg">
|
||||||
|
<svg class="w-5 h-5" :class="categoryMeta(r.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path v-for="(p, i) in categoryMeta(r.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span class="block text-sm text-white truncate">{{ r.name }}</span>
|
||||||
|
<span class="block text-[11px] text-white/40 truncate">{{ r.detail }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-[10px] px-2 py-0.5 rounded-full shrink-0"
|
||||||
|
:class="r.source === 'mine' ? 'bg-blue-500/15 text-blue-400' : 'bg-purple-500/15 text-purple-400'"
|
||||||
|
>{{ r.source === 'mine' ? 'My Files' : r.peerName }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
||||||
|
<div v-else-if="activeTab === 'peers'">
|
||||||
|
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||||
|
<svg class="animate-spin h-4 w-4" 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>
|
||||||
|
Fetching files from {{ peerNodes.length || '' }} peer{{ peerNodes.length === 1 ? '' : 's' }}…
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="peerNodes.length === 0" class="glass-card p-8 text-center">
|
||||||
|
<p class="text-white/60 mb-3">No peers yet. Set up federation to browse files shared by other nodes.</p>
|
||||||
|
<RouterLink to="/dashboard/server/federation" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Open Federation
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||||
|
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="f in filteredPeerFiles"
|
||||||
|
:key="f.key"
|
||||||
|
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
|
||||||
|
@click="router.push({ name: 'peer-files', params: { peerId: f.peerOnion } })"
|
||||||
|
>
|
||||||
|
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(f.category).iconBg">
|
||||||
|
<svg class="w-5 h-5" :class="categoryMeta(f.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path v-for="(p, i) in categoryMeta(f.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span class="block text-sm text-white truncate">{{ f.filename }}</span>
|
||||||
|
<span class="block text-[11px] text-white/40 truncate">{{ formatSize(f.sizeBytes) }}<template v-if="f.priceSats"> · {{ f.priceSats.toLocaleString() }} sats</template></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
|
||||||
|
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═════════════ All Files / My Files — section (+ peer) cards ═════════════ -->
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="section in contentSections"
|
v-for="section in visibleSections"
|
||||||
:key="section.id"
|
:key="section.id"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -48,7 +201,9 @@
|
|||||||
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Individual Peer Cards -->
|
|
||||||
|
<!-- Individual Peer Cards (All Files tab only) -->
|
||||||
|
<template v-if="activeTab === 'all'">
|
||||||
<div
|
<div
|
||||||
v-for="peer in peerNodes"
|
v-for="peer in peerNodes"
|
||||||
:key="peer.did"
|
:key="peer.did"
|
||||||
@@ -96,7 +251,7 @@
|
|||||||
|
|
||||||
<!-- No Peers placeholder (only if no peers found) -->
|
<!-- No Peers placeholder (only if no peers found) -->
|
||||||
<div
|
<div
|
||||||
v-if="!peersLoading && peerNodes.length === 0"
|
v-if="!peersLoading && peerNodes.length === 0 && selectedCategory === 'all'"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||||
@@ -123,15 +278,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-if="loadError" class="alert-error mb-4">
|
<div v-if="loadError" class="alert-error mt-4">
|
||||||
{{ loadError }}
|
{{ loadError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not Installed Hint -->
|
<!-- Not Installed Hint -->
|
||||||
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
|
<div v-if="!fileBrowserRunning && !searchActive && activeTab !== 'peers'" class="glass-card p-8 mt-6 text-center">
|
||||||
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||||
Open App Store
|
Open App Store
|
||||||
@@ -141,7 +297,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||||
@@ -152,6 +308,27 @@ const store = useAppStore()
|
|||||||
const sectionCounts = ref<Record<string, number>>({})
|
const sectionCounts = ref<Record<string, number>>({})
|
||||||
const countsLoading = ref(false)
|
const countsLoading = ref(false)
|
||||||
|
|
||||||
|
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||||
|
type TabId = 'all' | 'mine' | 'peers'
|
||||||
|
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
||||||
|
|
||||||
|
const TABS: Array<{ id: TabId; name: string }> = [
|
||||||
|
{ id: 'all', name: 'All Files' },
|
||||||
|
{ id: 'mine', name: 'My Files' },
|
||||||
|
{ id: 'peers', name: 'Peer Files' },
|
||||||
|
]
|
||||||
|
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
||||||
|
{ id: 'all', name: 'All' },
|
||||||
|
{ id: 'photos', name: 'Photos & Video' },
|
||||||
|
{ id: 'music', name: 'Music' },
|
||||||
|
{ id: 'documents', name: 'Documents' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref<TabId>('all')
|
||||||
|
const selectedCategory = ref<CategoryId>('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||||
|
|
||||||
interface PeerNode {
|
interface PeerNode {
|
||||||
did: string
|
did: string
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -237,6 +414,13 @@ const contentSections: ContentSection[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Section cards for the current tab, narrowed by the category pills. The
|
||||||
|
* browse-everything "All Files" card only makes sense unfiltered. */
|
||||||
|
const visibleSections = computed(() => {
|
||||||
|
if (selectedCategory.value === 'all') return contentSections
|
||||||
|
return contentSections.filter(s => s.id === selectedCategory.value)
|
||||||
|
})
|
||||||
|
|
||||||
const SECTION_PATHS: Record<string, string> = {
|
const SECTION_PATHS: Record<string, string> = {
|
||||||
photos: '/Photos',
|
photos: '/Photos',
|
||||||
music: '/Music',
|
music: '/Music',
|
||||||
@@ -244,6 +428,208 @@ const SECTION_PATHS: Record<string, string> = {
|
|||||||
files: '/',
|
files: '/',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Category helpers ─────────────────────────────────────────────────────────
|
||||||
|
function categoryOf(nameOrMime: string): Exclude<CategoryId, 'all'> {
|
||||||
|
const s = nameOrMime.toLowerCase()
|
||||||
|
if (s.startsWith('image/') || s.startsWith('video/') || /\.(jpe?g|png|gif|webp|heic|svg|mp4|mov|mkv|webm|avi)$/.test(s)) return 'photos'
|
||||||
|
if (s.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/.test(s)) return 'music'
|
||||||
|
return 'documents'
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_SECTION: ContentSection = contentSections[2]!
|
||||||
|
function categoryMeta(cat: Exclude<CategoryId, 'all'>): ContentSection {
|
||||||
|
return contentSections.find(s => s.id === cat) ?? FALLBACK_SECTION
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!bytes) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peer files (aggregated across every federation peer) ────────────────────
|
||||||
|
interface PeerFileEntry {
|
||||||
|
key: string
|
||||||
|
filename: string
|
||||||
|
sizeBytes: number
|
||||||
|
priceSats: number
|
||||||
|
category: Exclude<CategoryId, 'all'>
|
||||||
|
peerName: string
|
||||||
|
peerOnion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerFiles = ref<PeerFileEntry[]>([])
|
||||||
|
const peerFilesLoading = ref(false)
|
||||||
|
const peerFilesLoaded = ref(false)
|
||||||
|
const peerFilesErrors = ref(0)
|
||||||
|
|
||||||
|
interface CatalogItem {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
mime_type: string
|
||||||
|
size_bytes: number
|
||||||
|
description: string
|
||||||
|
access: string | { paid: { price_sats: number } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceOf(access: CatalogItem['access']): number {
|
||||||
|
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
|
||||||
|
async function loadPeerFiles(force = false) {
|
||||||
|
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
|
||||||
|
peerFilesLoading.value = true
|
||||||
|
peerFilesErrors.value = 0
|
||||||
|
try {
|
||||||
|
if (peerNodes.value.length === 0) await loadPeers()
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
peerNodes.value.map(async (peer) => {
|
||||||
|
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||||
|
method: 'content.browse-peer',
|
||||||
|
params: { onion: peer.onion },
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
return { peer, items: res?.items ?? [] }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const merged: PeerFileEntry[] = []
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
|
||||||
|
const { peer, items } = r.value
|
||||||
|
const peerName = peer.name || peerDisplayName(peer.did)
|
||||||
|
for (const item of items) {
|
||||||
|
merged.push({
|
||||||
|
key: `${peer.onion}:${item.id}`,
|
||||||
|
filename: item.filename,
|
||||||
|
sizeBytes: item.size_bytes,
|
||||||
|
priceSats: priceOf(item.access),
|
||||||
|
category: categoryOf(item.mime_type || item.filename),
|
||||||
|
peerName,
|
||||||
|
peerOnion: peer.onion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||||
|
peerFiles.value = merged
|
||||||
|
peerFilesLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
peerFilesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPeerFiles = computed(() =>
|
||||||
|
selectedCategory.value === 'all'
|
||||||
|
? peerFiles.value
|
||||||
|
: peerFiles.value.filter(f => f.category === selectedCategory.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch the aggregated list lazily, the first time the tab (or a search) needs it.
|
||||||
|
watch(activeTab, (tab) => { if (tab === 'peers') void loadPeerFiles() })
|
||||||
|
|
||||||
|
// ── Search (own files + all peer files) ─────────────────────────────────────
|
||||||
|
interface SearchResult {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
detail: string
|
||||||
|
category: Exclude<CategoryId, 'all'>
|
||||||
|
source: 'mine' | 'peer'
|
||||||
|
peerName?: string
|
||||||
|
peerOnion?: string
|
||||||
|
sectionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const searching = ref(false)
|
||||||
|
const searchResults = ref<SearchResult[]>([])
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let searchSeq = 0
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
if (!searchActive.value) { searchResults.value = []; searching.value = false; return }
|
||||||
|
searching.value = true
|
||||||
|
searchTimer = setTimeout(() => void runSearch(), 350)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Depth-limited walk of the own-file sections via the FileBrowser API. */
|
||||||
|
async function searchOwnFiles(query: string): Promise<SearchResult[]> {
|
||||||
|
if (!fileBrowserRunning.value) return []
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const out: SearchResult[] = []
|
||||||
|
try {
|
||||||
|
const ok = await fileBrowserClient.login()
|
||||||
|
if (!ok) return []
|
||||||
|
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||||
|
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||||
|
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||||
|
while (queue.length > 0 && out.length < 100) {
|
||||||
|
const { path, depth } = queue.shift()!
|
||||||
|
let items
|
||||||
|
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||||
|
if (item.isDir) {
|
||||||
|
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||||
|
} else if (item.name.toLowerCase().includes(q)) {
|
||||||
|
out.push({
|
||||||
|
key: `mine:${itemPath}`,
|
||||||
|
name: item.name,
|
||||||
|
detail: itemPath,
|
||||||
|
category: categoryOf(item.name),
|
||||||
|
source: 'mine',
|
||||||
|
sectionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* own-file search is best-effort; peer results still render */ }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const query = searchQuery.value.trim()
|
||||||
|
const seq = ++searchSeq
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
// Peer catalog piggybacks on the aggregated list (cached after first load).
|
||||||
|
const [mine] = await Promise.all([searchOwnFiles(query), loadPeerFiles()])
|
||||||
|
if (seq !== searchSeq) return // a newer query superseded this run
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const peers: SearchResult[] = peerFiles.value
|
||||||
|
.filter(f => f.filename.toLowerCase().includes(q))
|
||||||
|
.map(f => ({
|
||||||
|
key: `peer:${f.key}`,
|
||||||
|
name: f.filename,
|
||||||
|
detail: `${formatSize(f.sizeBytes)}${f.priceSats ? ` · ${f.priceSats.toLocaleString()} sats` : ''}`,
|
||||||
|
category: f.category,
|
||||||
|
source: 'peer' as const,
|
||||||
|
peerName: f.peerName,
|
||||||
|
peerOnion: f.peerOnion,
|
||||||
|
}))
|
||||||
|
searchResults.value = [...mine, ...peers]
|
||||||
|
} finally {
|
||||||
|
if (seq === searchSeq) searching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSearchResults = computed(() =>
|
||||||
|
selectedCategory.value === 'all'
|
||||||
|
? searchResults.value
|
||||||
|
: searchResults.value.filter(r => r.category === selectedCategory.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
function openSearchResult(r: SearchResult) {
|
||||||
|
if (r.source === 'peer' && r.peerOnion) {
|
||||||
|
router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })
|
||||||
|
} else if (r.sectionId) {
|
||||||
|
router.push({ name: 'cloud-folder', params: { folderId: r.sectionId } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Existing counts / peers loading ──────────────────────────────────────────
|
||||||
async function loadCounts() {
|
async function loadCounts() {
|
||||||
if (!fileBrowserRunning.value) return
|
if (!fileBrowserRunning.value) return
|
||||||
countsLoading.value = true
|
countsLoading.value = true
|
||||||
|
|||||||
@@ -441,16 +441,16 @@
|
|||||||
switch to the other if it has enough balance. -->
|
switch to the other if it has enough balance. -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<button
|
<button
|
||||||
v-for="b in (['cashu', 'fedimint'] as const)"
|
v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
|
||||||
:key="b"
|
:key="b"
|
||||||
@click="ecashPlan.chosen = b"
|
@click="ecashPlan.chosen = b"
|
||||||
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
||||||
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
||||||
>
|
>
|
||||||
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
|
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : b === 'fedimint' ? '🤝' : '⚓' }}</span>
|
||||||
<span class="flex-1 min-w-0">
|
<span class="flex-1 min-w-0">
|
||||||
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
|
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : b === 'fedimint' ? 'Fedimint' : 'Ark' }}</span>
|
||||||
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
|
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
|
||||||
</span>
|
</span>
|
||||||
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -707,10 +707,11 @@ const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
|
|||||||
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
||||||
// we look at both balances, decide which backend covers the price, and show a
|
// we look at both balances, decide which backend covers the price, and show a
|
||||||
// confirm screen so they see (and can switch) which ecash is spent (#3).
|
// confirm screen so they see (and can switch) which ecash is spent (#3).
|
||||||
type EcashBackend = 'cashu' | 'fedimint'
|
type EcashBackend = 'cashu' | 'fedimint' | 'ark'
|
||||||
const ecashPlan = ref<{
|
const ecashPlan = ref<{
|
||||||
cashu: number
|
cashu: number
|
||||||
fedimint: number
|
fedimint: number
|
||||||
|
ark: number
|
||||||
total: number
|
total: number
|
||||||
chosen: EcashBackend | null
|
chosen: EcashBackend | null
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -1135,7 +1136,7 @@ async function pollOnchain(address: string) {
|
|||||||
/** Spendable balance for a given ecash backend in the current plan. */
|
/** Spendable balance for a given ecash backend in the current plan. */
|
||||||
function ecashBalanceOf(b: EcashBackend): number {
|
function ecashBalanceOf(b: EcashBackend): number {
|
||||||
if (!ecashPlan.value) return 0
|
if (!ecashPlan.value) return 0
|
||||||
return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint
|
return b === 'cashu' ? ecashPlan.value.cashu : b === 'fedimint' ? ecashPlan.value.fedimint : ecashPlan.value.ark
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1152,23 +1153,25 @@ async function prepareEcashPay() {
|
|||||||
try {
|
try {
|
||||||
let cashu = 0
|
let cashu = 0
|
||||||
let fedimint = 0
|
let fedimint = 0
|
||||||
|
let ark = 0
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; total_sats?: number; balance_sats?: number }>({
|
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; ark_sats?: number; total_sats?: number; balance_sats?: number }>({
|
||||||
method: 'wallet.ecash-balance',
|
method: 'wallet.ecash-balance',
|
||||||
})
|
})
|
||||||
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
|
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
|
||||||
fedimint = res?.fedimint_sats ?? 0
|
fedimint = res?.fedimint_sats ?? 0
|
||||||
|
ark = res?.ark_sats ?? 0
|
||||||
} catch {
|
} catch {
|
||||||
// Couldn't read balances — let the user try anyway (auto backend).
|
// Couldn't read balances — let the user try anyway (auto backend).
|
||||||
}
|
}
|
||||||
const total = cashu + fedimint
|
const total = cashu + fedimint + ark
|
||||||
// Prefer Cashu when it covers the price, else Fedimint, else leave null
|
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
|
||||||
// (insufficient — shown in the confirm screen, Confirm disabled).
|
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
|
||||||
const chosen: EcashBackend | null =
|
const chosen: EcashBackend | null =
|
||||||
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null
|
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
|
||||||
ecashPlan.value = { cashu, fedimint, total, chosen }
|
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
purchaseError.value = `Not enough ecash: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Fund a wallet, or pay another way.`
|
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
|
||||||
}
|
}
|
||||||
payMode.value = 'ecash-confirm'
|
payMode.value = 'ecash-confirm'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -630,7 +630,11 @@ async function applyDnsConfig(customServers: string) {
|
|||||||
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
|
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
|
||||||
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
|
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
|
||||||
const res = await rpcClient.configureDns(params)
|
const res = await rpcClient.configureDns(params)
|
||||||
networkData.value.dnsProvider = res.provider; networkData.value.dnsServers = res.servers; networkData.value.dnsDoH = res.doh_enabled
|
// Never trust the response shape: an undefined `servers` used to reach the
|
||||||
|
// dnsDisplayLabel computed and crash the whole page render on `.length`.
|
||||||
|
networkData.value.dnsProvider = res?.provider ?? provider
|
||||||
|
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
|
||||||
|
networkData.value.dnsDoH = !!res?.doh_enabled
|
||||||
showDnsModal.value = false
|
showDnsModal.value = false
|
||||||
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- WiFi Scan Modal -->
|
<!-- WiFi Scan Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
|
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
|
||||||
<div class="glass-card p-6 w-full max-w-md">
|
<div class="glass-card p-6 w-full max-w-md">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -161,8 +162,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- DNS Configuration Modal -->
|
<!-- DNS Configuration Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
|
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
|
||||||
<div class="glass-card p-6 w-full max-w-md">
|
<div class="glass-card p-6 w-full max-w-md">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -223,6 +226,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
Reference in New Issue
Block a user