Compare commits

...
Author SHA1 Message Date
DorianandClaude Fable 5 46dd853614 fix(ui): heavier scrim over the bright web5/server backdrop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 977b8d06b8 fix(ui): Connect to Mesh routes to /dashboard/mesh, not the 404 /mesh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 f08f34ca10 fix(ui): card actions live at the bottom on every screen size
Retire the >=1800px compact-header variant globally (Monitoring Details,
Federation Find Nodes/Fleet, Identities Create, VPN Add Device, Network
Interfaces Scan WiFi, Tor Restart/Add Service). Tor service rows get a
50/50 Delete|Rotate row underneath — Delete on the left, away from the
enable toggle, so no missclicks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 aaa3477d5e feat(ui): Networking Profits becomes a dashboard
Dashboard tab (default): stat tiles per earning source, 7-day earnings
LineChart (one line per source, daily buckets — earnings are sparse
events so finer buckets would draw noise), active-session count and
revenue-by-service bars from streaming.list-sessions. Configure tab
keeps the old settings screen with the intro copy boxed in a card, plus
a new TollGate WiFi entry (price per minute via openwrt.provision-
tollgate, with a set-up-gateway hint when no router is paired). Demo
mocks gain profit history entries and streaming sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
lfg2025 90bedc2a25 Merge pull request 'feat(ui): WireGuard remote-access onboarding + Connected Nodes cleanup' (#95) from identities-mobile-polish into main
Demo images / Build & push demo images (push) Successful in 2m38s
2026-07-17 01:39:02 +00:00
DorianandClaude Fable 5 a66e4bac6d fix(ui): Connected Nodes — consistent bottom actions
Drop the 'Discover Nodes on Nostr' button and the >=1800px header
buttons; Find Nodes + Refresh are now a 50/50 bottom pair on every
screen size, with Refresh acting on the active tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:38:07 +01:00
DorianandClaude Fable 5 af2dfd0bd6 chore(ui): mirror official WireGuard Android APK for the pairing flow
com.wireguard.android 1.0.20260315 (versionCode 519) from
download.wireguard.com, signer WireGuard LLC (cert sha256 84a13fa2…),
file sha256 f92971bc804f4c448e8845ae97e426095eab06ec09a2473a3fa2cfe7288e3298.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:32:02 +01:00
DorianandClaude Fable 5 68c25534d4 feat(ui): remote-access steps in the companion pairing flow
After 'I've installed it' the modal now walks WireGuard setup before
pairing: install WireGuard (hosted APK + QR), scan the node's tunnel
config (auto-provisioned 'companion-phone' peer via the existing
vpn.create-peer/peer-config RPCs, rendered client-side), switch it on,
then pair. Real nodes pair against http://10.44.0.1 — the tunnel only
routes AllowedIPs 10.44.0.0/16, so the LAN origin is unreachable
remotely. The demo fakes the tunnel via mock RPCs and keeps its public
pairing URL. REMOTE_ACCESS_STEPS=false restores the old two-screen flow.
Phones get the config as a downloadable .conf (can't scan own screen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:32:02 +01:00
lfg2025 45c9def94a Merge pull request 'fix(ui): web5 mobile polish — identity profile cards, visibility card, demo avatars, title alignment' (#94) from identities-mobile-polish into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-17 01:13:56 +00:00
DorianandClaude Fable 5 299f7d8f39 fix(ui): center OpenWrt Gateway title with its back button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:12:54 +01:00
DorianandClaude Fable 5 bb231e82b4 fix(ui): web5 cards mobile polish + demo identity avatars
- Identities render as profile cards (banner, overlapping avatar, bio)
  in narrow containers via container query; wide/desktop row untouched
- Demo identities get purpose-tinted SVG avatars (public/demo-avatars)
  in both mock identity.list/get branches
- Node Visibility: description stacks under icon+title on mobile,
  Refresh is a full-width bottom action on all sizes, discoverable
  warning line removed, and the demo no longer exposes an onion address

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:12:54 +01:00
lfg2025 e2309cc2ac Merge pull request 'fix(android): pairing QR now actually scannable (v0.4.14)' (#93) from companion-qr-scan-fix into main
Demo images / Build & push demo images (push) Successful in 2m31s
2026-07-17 01:04:08 +00:00
Dorian a48499daeb chore(android): update companion apk download 2026-07-17 02:03:27 +01:00
DorianandClaude Fable 5 6df9afe684 fix(android): pairing QR now actually scannable
Camera decode failed because every lever was at its minimum: the web
modal generated the QR at 112px (~2.5px per module before upscaling),
the analysis stream was CameraX's 640x480 default, and ZXing ran
without TRY_HARDER. Generate the QR at 512px (displayed 192px with a
real quiet zone), analyze at 1280x720, decode with TRY_HARDER plus an
inverted-luminance fallback. v0.4.14 (versionCode 18).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:03:11 +01:00
lfg2025 9d21dd5111 Merge pull request 'feat(android): companion QR pairing — scan, deep link, auto-login (v0.4.13)' (#92) from companion-qr-pairing into main
Demo images / Build & push demo images (push) Successful in 2m34s
2026-07-17 00:46:47 +00:00
archipelagoandClaude Fable 5 2e5f67a59a feat(ui): companion app banner in the App Store + APK download unblocked
Demo images / Build & push demo images (push) Has been cancelled
The App Store gains a "Your Node. In Your Pocket." hero banner for the
mobile companion app, in the same format as the featured-app banner, with
the app running on a phone mockup rising out of the right edge. Install
(or clicking anywhere on it) opens the Remote Companion download/pairing
modal — now openable on demand via a shared trigger instead of only
auto-showing once.

Also fixes the companion APK download on PWA installs: /packages/ now
bypasses the service worker's SPA fallback, which was answering the
download click with index.html instead of the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:46:08 -04:00
23 changed files with 1176 additions and 232 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 17
versionName = "0.4.13"
versionCode = 18
versionName = "0.4.14"
vectorDrawables {
useSupportLibrary = true
@@ -239,7 +239,12 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// CameraX's analysis default is 640x480 — too few pixels per module
// to decode a modal-sized QR at arm's length. 1280x720 more than
// doubles the pixel density at negligible analysis cost.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
@@ -268,7 +273,14 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
// Screen-displayed QRs come with moiré, glare, and soft focus at
// close range — the exhaustive search is worth the milliseconds.
DecodeHintType.TRY_HARDER to true,
)
)
}
override fun analyze(image: ImageProxy) {
@@ -284,7 +296,13 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
0, 0, image.width, image.height,
false,
)
val result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
// Dark-themed pages can render light-on-dark QRs — retry inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
}
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
+74 -7
View File
@@ -2032,6 +2032,7 @@ app.post('/rpc/v1', (req, res) => {
about: 'Self-sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com',
picture: '/demo-avatars/satoshi.svg',
},
},
{
@@ -2044,7 +2045,7 @@ app.post('/rpc/v1', (req, res) => {
is_default: false,
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
profile: { display_name: 'Anon' },
profile: { display_name: 'Anon', picture: '/demo-avatars/anon.svg' },
},
{
id: 'id-merchant',
@@ -2056,7 +2057,7 @@ app.post('/rpc/v1', (req, res) => {
is_default: false,
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
profile: { display_name: 'My Shop', website: 'https://myshop.onion', picture: '/demo-avatars/business.svg' },
},
],
},
@@ -2079,6 +2080,7 @@ app.post('/rpc/v1', (req, res) => {
profile: {
display_name: 'Archipelago Node',
about: 'Self-sovereign Bitcoin node',
picture: '/demo-avatars/satoshi.svg',
},
},
})
@@ -2121,6 +2123,7 @@ app.post('/rpc/v1', (req, res) => {
about: 'Running a sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com',
picture: '/demo-avatars/satoshi.svg',
},
},
{
@@ -2136,6 +2139,7 @@ app.post('/rpc/v1', (req, res) => {
profile: {
display_name: 'Archy Consulting',
about: 'Bitcoin infrastructure services',
picture: '/demo-avatars/business.svg',
},
},
{
@@ -2146,7 +2150,7 @@ app.post('/rpc/v1', (req, res) => {
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
created_at: '2026-03-01T18:00:00Z',
is_default: false,
profile: {},
profile: { picture: '/demo-avatars/anon.svg' },
},
],
},
@@ -2163,7 +2167,7 @@ app.post('/rpc/v1', (req, res) => {
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
is_default: true, created_at: '2026-01-10T08:00:00Z',
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node', picture: '/demo-avatars/satoshi.svg' },
},
}
return res.json({ result: identities[id] || identities['id-primary'] })
@@ -2235,12 +2239,40 @@ app.post('/rpc/v1', (req, res) => {
bytes_out: 642_889_310,
} })
}
// Fake tunnel provisioning so the companion remote-access onboarding
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
// are not real; importing this config yields a harmless dead tunnel.
case 'vpn.list-peers': {
return res.json({ result: { peers: mockState.vpnPeers || [] } })
}
case 'vpn.create-peer':
case 'vpn.peer-config': {
const peerName = params?.name || 'device'
if (!mockState.vpnPeers) mockState.vpnPeers = []
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
}
const config = [
'[Interface]',
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
'Address = 10.44.0.7/32',
'DNS = 1.1.1.1',
'',
'[Peer]',
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
'Endpoint = demo.archipelago-foundation.org:51820',
'AllowedIPs = 10.44.0.0/16',
'PersistentKeepalive = 25',
].join('\n')
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
}
// Node visibility — interactive (persisted per demo session)
case 'network.get-visibility': {
// No onion_address in the demo: the public showcase shouldn't display
// a Tor address (even a fake one) on the Node Visibility card.
return res.json({ result: {
visibility: mockState.nodeVisibility,
onion_address: mockData['server-info']['tor-address'],
} })
}
case 'network.set-visibility': {
@@ -2248,7 +2280,6 @@ app.post('/rpc/v1', (req, res) => {
if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v
return res.json({ result: {
visibility: mockState.nodeVisibility,
onion_address: mockData['server-info']['tor-address'],
} })
}
@@ -2351,6 +2382,23 @@ app.post('/rpc/v1', (req, res) => {
case 'streaming.list-services': {
return res.json({ result: { services: mockState.streamingServices || [] } })
}
case 'streaming.list-sessions': {
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
return res.json({ result: {
sessions: [
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
],
total_active: 3,
total_revenue_sats: 770_000,
revenue_by_service: {
'content-download': 512_400,
'nostr-relay': 201_600,
'api-access': 56_000,
},
} })
}
case 'streaming.configure-service': {
const p = params || {}
const list = mockState.streamingServices || []
@@ -3535,13 +3583,32 @@ app.post('/rpc/v1', (req, res) => {
}
case 'wallet.networking-profits': {
// Deterministic-but-varied week of profit events for the dashboard
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
const recent = []
const nowMs = Date.now()
for (let i = 0; i < 42; i++) {
const daysAgo = (i * 3) % 7
const hour = (i * 5) % 24
recent.push({
source: profitSources[i % 3],
amount_sats: 800 + ((i * 7919) % 14000),
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
description: profitNotes[i % 3],
})
}
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
return res.json({
result: {
total_sats: 5_231_978,
content_sales_sats: 3_180_000,
routing_fees_sats: 1_281_978,
relay_sats: 770_000,
streaming_revenue_sats: 770_000,
recent,
// legacy aliases kept for older UI builds
relay_sats: 770_000,
total_earned_sats: 5_231_978,
total_forwarded_sats: 1_281_978,
forward_count: 1284,
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7e22ce;stop-opacity:1" />
<stop offset="100%" style="stop-color:#231133;stop-opacity:1" />
</linearGradient>
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#d8b4fe;stop-opacity:0.5" />
</linearGradient>
</defs>
<rect width="240" height="240" fill="url(#bg)"/>
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
<!-- hooded figure, face in shadow -->
<path d="M120 44 C86 44 72 76 72 106 L72 132 L168 132 L168 106 C168 76 154 44 120 44 Z" fill="url(#fig)"/>
<ellipse cx="120" cy="104" rx="30" ry="34" fill="rgba(35,17,51,0.9)"/>
<path d="M45 240 C45 182 78 150 120 150 C162 150 195 182 195 240 Z" fill="url(#fig)"/>
</svg>

After

Width:  |  Height:  |  Size: 1000 B

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#c2540a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#3b1c0a;stop-opacity:1" />
</linearGradient>
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#fdba74;stop-opacity:0.55" />
</linearGradient>
</defs>
<rect width="240" height="240" fill="url(#bg)"/>
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
<!-- head + suited shoulders -->
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
<!-- collar + tie -->
<path d="M104 156 L120 176 L136 156 L120 148 Z" fill="rgba(59,28,10,0.85)"/>
<path d="M115 176 L125 176 L123 214 L120 220 L117 214 Z" fill="rgba(59,28,10,0.85)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1d4ed8;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
</linearGradient>
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#93c5fd;stop-opacity:0.55" />
</linearGradient>
</defs>
<rect width="240" height="240" fill="url(#bg)"/>
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
<!-- head + shoulders -->
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
</svg>

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.
Binary file not shown.
@@ -59,19 +59,137 @@
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download app
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="showPairScreen"
@click="advanceFromDownload"
>
I've installed it
</button>
</div>
</div>
<!-- Screen 2 (remote access): install WireGuard -->
<div v-else-if="step === 'wireguard'" key="wireguard">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
<p class="text-sm text-white/60 leading-relaxed">
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
</p>
</div>
</div>
<div class="hidden md:flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgApkQrDataUrl"
:src="wgApkQrDataUrl"
alt="WireGuard app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<div class="flex gap-2">
<a
:href="wireguardDownloadUrl"
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download WireGuard
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="showWgQrScreen"
>
I've installed it
</button>
</div>
<button
type="button"
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showDownloadScreen"
>
Back
</button>
</div>
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
<div v-else-if="step === 'wgqr'" key="wgqr">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
<p class="text-sm text-white/60 leading-relaxed">
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
</p>
</div>
</div>
<div class="flex justify-center mb-3">
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgQrDataUrl"
:src="wgQrDataUrl"
alt="WireGuard tunnel config QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
</div>
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
<button
v-if="wgConfig"
type="button"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
@click="downloadWgConfig"
>
Download tunnel config
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
@click="showPairScreen"
>
I'm connected
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showWireguardScreen('back')"
>
Back
</button>
</div>
<!-- Screen 2: pair the app with this node -->
<div v-else key="pair">
<div class="flex items-start gap-4 mb-4">
@@ -92,7 +210,9 @@
</div>
<div class="flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<!-- Bigger than the download QR: this one is scanned by the
companion app camera, and physical size drives decode. -->
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="pairQrDataUrl"
:src="pairQrDataUrl"
@@ -116,7 +236,7 @@
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showDownloadScreen"
@click="backFromPair"
>
Back
</button>
@@ -132,6 +252,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
import { companionIntroRequested } from '@/composables/useCompanionIntro'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { rpcClient } from '@/api/rpc-client'
@@ -150,13 +271,43 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
const PAIR_SCHEME = 'archipelago://pair'
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
// between "I've installed it" and the pairing QR. Real nodes pair against the
// node's VPN address instead of the LAN origin; the demo walks the same steps
// with a mock tunnel config (its pairing QR still carries the public demo
// URL). Set to false to restore the original two-screen flow (instant revert).
const REMOTE_ACCESS_STEPS = true
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
// 10.44.0.0/16 core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
// the node is reachable exclusively at its tunnel address. The pairing QR
// must therefore advertise this, not the browser's LAN origin. The flow has
// the user switch the tunnel on right before pairing, so it validates.
const VPN_NODE_URL = 'http://10.44.0.1'
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
// sha256 f92971bc, mirrored from download.wireguard.com), served like the
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/wireguard.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
// Name of the auto-created VPN peer for the phone running the companion.
const WG_PEER_NAME = 'companion-phone'
const visible = ref(false)
const step = ref<'download' | 'pair'>('download')
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download')
const slideName = ref('slide-forward')
const qrDataUrl = ref('')
const pairQrDataUrl = ref('')
const pairingUrl = ref('')
const wgApkQrDataUrl = ref('')
const wgQrDataUrl = ref('')
const wgConfig = ref('')
const wgLoading = ref(false)
const wgError = ref('')
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
const loginTransition = useLoginTransitionStore()
@@ -202,17 +353,43 @@ function maybeShow() {
}, 250)
}
// Manual open (App Store banner etc.) ignores the once-per-browser gate.
watch(companionIntroRequested, (requested) => {
if (!requested) return
companionIntroRequested.value = false
if (calmTicker) {
clearInterval(calmTicker)
calmTicker = null
}
step.value = 'download'
visible.value = true
})
watch(visible, async (isVisible) => {
if (!isVisible) return
// Generate large and let CSS scale down at 112px source a ~45-module QR
// is 2.5px/module, which camera decoders (the companion app included)
// routinely fail on. 512px keeps every module crisp.
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
width: 112,
margin: 1,
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
}, { immediate: true, flush: 'post' })
/**
@@ -223,6 +400,9 @@ watch(visible, async (isVisible) => {
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
// Remote-access flow: the app must connect through the tunnel the user just
// switched on the LAN origin is unreachable from outside AllowedIPs.
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
const { hostname, origin } = window.location
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
try {
@@ -247,9 +427,11 @@ async function showPairScreen() {
step.value = 'pair'
if (!pairQrDataUrl.value) {
pairingUrl.value = await buildPairingUrl()
// Large source + a real quiet zone; this QR is scanned by the companion
// app's camera, so give it every advantage (see download QR note above).
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
width: 112,
margin: 1,
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
@@ -264,6 +446,77 @@ function showDownloadScreen() {
step.value = 'download'
}
function advanceFromDownload() {
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
else void showPairScreen()
}
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
step.value = 'wireguard'
}
function showWgQrScreen() {
slideName.value = 'slide-forward'
step.value = 'wgqr'
void loadWgPeer()
}
function backFromPair() {
if (REMOTE_ACCESS_STEPS) {
slideName.value = 'slide-back'
step.value = 'wgqr'
} else {
showDownloadScreen()
}
}
// Create (or fetch) the phone's VPN peer and render its config as a QR.
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
// looked up first so reopening the modal never duplicates it.
async function loadWgPeer() {
if (wgQrDataUrl.value || wgLoading.value) return
wgLoading.value = true
wgError.value = ''
try {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => ({ peers: [] as { name: string }[] }))
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
params: { name: WG_PEER_NAME },
})
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
} catch (e) {
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
} finally {
wgLoading.value = false
}
}
// Same-device path: hand the config to the WireGuard app as an importable
// .conf file (a phone can't scan its own screen).
function downloadWgConfig() {
if (!wgConfig.value) return
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'archipelago.conf'
a.click()
URL.revokeObjectURL(url)
}
function dismiss() {
visible.value = false
step.value = 'download'
@@ -216,7 +216,8 @@ async function connect() {
device_kind: form.value.deviceKind,
})
mesh.dismissDetectedDevice(path)
void router.push('/mesh')
// The Mesh view lives under the dashboard shell a bare /mesh 404s.
void router.push('/dashboard/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
@@ -0,0 +1,13 @@
import { ref } from 'vue'
/**
* Cross-view trigger for the Remote Companion intro/pairing modal
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
* App Store banner call openCompanionIntro() to pop it on demand this
* bypasses the once-per-browser auto-show gate.
*/
export const companionIntroRequested = ref(false)
export function openCompanionIntro(): void {
companionIntroRequested.value = true
}
+3 -15
View File
@@ -79,7 +79,9 @@ select:focus-visible {
border-color: rgba(251, 146, 60, 0.4);
}
/* Card action placement: keep compact header buttons for genuinely wide layouts. */
/* Card action placement: actions always live at the bottom of the card as
full-width buttons same layout on every screen size. The compact header
variants are permanently retired for consistency (desktop == mobile). */
.responsive-card-actions-top,
.web5-card-actions-top {
display: none;
@@ -109,20 +111,6 @@ select:focus-visible {
white-space: nowrap;
}
@media (min-width: 1800px) {
.responsive-card-actions-top,
.web5-card-actions-top {
display: flex;
}
.responsive-card-actions-bottom,
.responsive-card-actions-bottom-grid,
.web5-card-actions-bottom,
.web5-card-actions-bottom-grid {
display: none;
}
}
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
+8 -2
View File
@@ -52,9 +52,11 @@
aria-hidden="true"
/>
<!-- Background overlay - uniform 0.2 opacity -->
<!-- Background overlay. The web5/server backdrop is a much lighter image
than the others, so it gets a heavier scrim for text contrast. -->
<div
class="fixed inset-0 pointer-events-none bg-black/20"
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500"
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
style="z-index: -5;"
/>
@@ -190,6 +192,10 @@ const backgroundImage = computed(() => {
return 'bg-home.webp'
})
// bg-web5.jpg (web5 + server sections) is bright the scrim overlay deepens
// while it's showing so light text keeps its contrast.
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
const isDarkRoute = computed(() => {
const p = route.path
return p.includes('/dashboard/web5') ||
+4
View File
@@ -150,6 +150,9 @@
</div>
</div>
<!-- Mobile companion app banner opens the download/pairing modal -->
<CompanionBanner />
<!-- Category Section Divider -->
<div class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">all</span>
@@ -239,6 +242,7 @@ import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout
import { APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import CompanionBanner from './discover/CompanionBanner.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
@@ -0,0 +1,119 @@
<template>
<!-- Companion app banner same format as the featured app banner, with a
phone mockup rising out of the right edge. Clicking anywhere (or the
Install button) opens the Remote Companion download/pairing modal. -->
<div
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="openCompanionIntro()"
>
<img
src="/assets/img/bg-intro-4.webp"
alt=""
class="featured-banner-img"
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
/>
<div class="featured-banner-overlay companion-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">companion</span>
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
The Archipelago Companion puts your node on your phone remote control and typing,
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
</p>
<div class="flex items-center gap-3">
<button
@click.stop="openCompanionIntro()"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>Install</button>
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
</div>
</div>
<div class="companion-phone-wrap" aria-hidden="true">
<div class="companion-phone">
<span class="companion-phone-lens"></span>
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { openCompanionIntro } from '@/composables/useCompanionIntro'
</script>
<style scoped>
.companion-banner {
border-color: rgba(251, 146, 60, 0.25);
}
/* Keep the copy clear of the phone mockup on wide screens */
@media (min-width: 768px) {
.companion-banner-overlay {
padding-right: 320px;
}
}
.companion-phone-wrap {
display: none;
}
@media (min-width: 768px) {
.companion-phone-wrap {
display: block;
position: absolute;
top: 2rem;
right: 3.5rem;
z-index: 2;
transform: rotate(6deg);
transition: transform 0.4s ease;
}
.companion-banner:hover .companion-phone-wrap {
transform: rotate(4deg) translateY(-8px);
}
}
.companion-phone {
position: relative;
width: 200px;
padding: 9px;
border-radius: 30px;
background: #0b0b12;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.55),
0 0 46px rgba(251, 146, 60, 0.28),
inset 0 0 2px rgba(255, 255, 255, 0.18);
}
/* Punch-hole camera */
.companion-phone-lens {
position: absolute;
top: 17px;
left: 50%;
transform: translateX(-50%);
width: 9px;
height: 9px;
border-radius: 9999px;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.14);
z-index: 2;
}
.companion-phone-screen {
display: block;
width: 100%;
border-radius: 22px;
}
/* Screen glare */
.companion-phone::after {
content: '';
position: absolute;
inset: 9px;
border-radius: 22px;
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
pointer-events: none;
}
</style>
+3 -1
View File
@@ -368,8 +368,10 @@ onMounted(() => load())
<template>
<div class="pb-6">
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
title's centerline in this inline header row. -->
<div class="flex items-center gap-3 mb-6">
<BackButton @click="goBack" />
<BackButton desktop-margin="mb-0" @click="goBack" />
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
</div>
+32 -25
View File
@@ -30,40 +30,47 @@
</svg>
Refreshing Tor services...
</div>
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
<ToggleSwitch class="shrink-0" :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</div>
<div class="flex items-center gap-2 shrink-0">
<button
v-if="svc.onion_address && svc.enabled"
@click="$emit('rotateService', svc.name)"
:disabled="torRotating === svc.name"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<!-- Actions in their own 50/50 row: Delete on the LEFT, far from the
toggle above, so a rushed thumb can't hit the wrong control. -->
<div
v-if="svc.name !== 'archipelago' || (svc.onion_address && svc.enabled)"
class="grid grid-cols-2 gap-2 mt-3"
>
<button
v-if="svc.name !== 'archipelago'"
@click="$emit('deleteService', svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
class="glass-button py-2 rounded-lg text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
:class="{ 'col-span-2': !(svc.onion_address && svc.enabled) }"
:title="'Delete ' + svc.name + ' hidden service'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ torDeleting === svc.name ? 'Deleting...' : 'Delete' }}
</button>
<button
v-if="svc.onion_address && svc.enabled"
@click="$emit('rotateService', svc.name)"
:disabled="torRotating === svc.name"
class="glass-button py-2 rounded-lg text-xs disabled:opacity-50"
:class="{ 'col-span-2': svc.name === 'archipelago' }"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</div>
</div>
</div>
+12 -66
View File
@@ -11,21 +11,6 @@
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
</div>
<div class="web5-card-actions-top gap-2 shrink-0">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
</div>
</div>
<!-- Mobile: stacked layout -->
<div class="md:hidden mb-4">
@@ -174,7 +159,9 @@
</div>
<div class="mt-auto pt-4 space-y-3">
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
<!-- Always the bottom 50/50 pair, every screen size consistent with
the other web5 cards' full-width bottom actions. -->
<div class="grid grid-cols-2 gap-3">
<button
@click="router.push('/dashboard/server/federation')"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
@@ -182,37 +169,13 @@
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
@click="refreshActiveTab"
:disabled="loadingPeers || loadingRequests"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
</button>
</div>
<button
v-if="nodesContainerTab === 'trusted'"
@click="discoverAndAddPeers"
:disabled="discovering"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
<button
v-else-if="nodesContainerTab === 'observers'"
@click="loadPeers"
:disabled="loadingPeers"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
<button
v-else
@click="loadConnectionRequests"
:disabled="loadingRequests"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
</div>
@@ -328,7 +291,6 @@ const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false)
// Send message modal
const showSendMessageModal = ref(false)
@@ -378,6 +340,12 @@ function switchToRequestsTab() {
}
}
// The single bottom Refresh acts on whichever tab is open.
function refreshActiveTab() {
if (nodesContainerTab.value === 'requests') void loadConnectionRequests()
else void loadPeers()
}
async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true
@@ -456,28 +424,6 @@ async function sendMessage() {
}
}
async function discoverAndAddPeers() {
discovering.value = true
try {
const res = await rpcClient.discoverNodes()
const nodes = res.nodes || []
for (const n of nodes) {
if (n.onion && n.pubkey) {
try {
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
} catch (e) {
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
}
}
}
await loadPeers()
} catch (e) {
if (import.meta.env.DEV) console.error('Discover failed:', e)
} finally {
discovering.value = false
}
}
async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0
loadingRequests.value = true
+108 -2
View File
@@ -61,9 +61,91 @@
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': idx }"
>
<!-- Narrow containers (phones): profile-card layout with banner +
overlapping avatar. Hidden on wide containers where the classic
row below renders instead. -->
<div class="identity-card-m rounded-xl overflow-hidden bg-white/[0.08]">
<div class="relative h-16" :class="bannerFallback(identity)">
<img
v-if="identity.profile?.banner"
:src="displayableUrl(identity.profile.banner)"
class="absolute inset-0 w-full h-full object-cover"
/>
</div>
<div class="px-4 pb-4">
<div class="flex items-end justify-between -mt-7">
<button @click="openProfileEditor(identity)" class="relative w-14 h-14 rounded-full overflow-hidden ring-4 ring-black/60 group shrink-0" title="Edit profile">
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-lg font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<div class="flex items-center gap-1 pt-8">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2 flex-wrap min-w-0">
<span class="text-white font-semibold text-sm truncate">{{ identity.profile?.display_name || identity.name }}</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<p v-if="identity.profile?.about" class="text-white/60 text-xs mt-1 leading-relaxed">{{ identity.profile.about }}</p>
<div class="mt-2 space-y-0.5">
<div class="flex items-center gap-1 min-w-0">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 min-w-0">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Wide containers: the original row desktop appearance unchanged -->
<div class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg">
<div class="identity-row-main flex items-center gap-4 min-w-0">
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
@@ -134,6 +216,7 @@
</svg>
</button>
</div>
</div>
</div>
</div>
@@ -465,6 +548,16 @@ watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
// URLs and plain external URLs pass through untouched.
// Gradient banner backdrop for the narrow profile card when the identity has
// no banner image, tinted by purpose to match the avatar/chip colors.
function bannerFallback(identity: ManagedIdentity): string {
switch (identity.purpose) {
case 'business': return 'bg-gradient-to-br from-orange-500/40 to-amber-500/15'
case 'anonymous': return 'bg-gradient-to-br from-purple-500/40 to-fuchsia-500/15'
default: return 'bg-gradient-to-br from-blue-500/40 to-indigo-500/15'
}
}
function displayableUrl(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('/')) return url
@@ -710,8 +803,21 @@ defineExpose({ loadIdentities, managedIdentities })
container-name: identities-card;
}
/* Narrow containers (default): the profile card renders and the classic row
is hidden. Wide containers: the original desktop row untouched and the
profile card hides. Doubled selectors out-rank the Tailwind utilities on
the same elements regardless of stylesheet order. */
.identities-card .identity-row {
display: none;
}
@container identities-card (min-width: 560px) {
.identity-row {
.identities-card .identity-card-m {
display: none;
}
.identities-card .identity-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
const router = useRouter()
@@ -23,6 +25,10 @@ interface ServicePricing {
accepted_mints: string[]
}
// Tabs
const tab = ref<'dashboard' | 'configure'>('dashboard')
// Configure state
const services = ref<ServicePricing[]>([])
const loading = ref(true)
const loadError = ref('')
@@ -32,7 +38,7 @@ const statusIsError = ref(false)
// "Free everything" is the default every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled))
const allFree = computed(() => services.value.every((s) => !s.enabled) && !tollgate.value?.enabled)
function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg
@@ -113,86 +119,432 @@ async function saveService(svc: ServicePricing) {
}
}
onMounted(load)
// TollGate (paid WiFi on the OpenWrt gateway)
interface TollGateStatus {
installed: boolean
enabled?: boolean
step_size_ms?: number
price_per_step?: number
min_steps?: number
mint_url?: string
}
const tollgate = ref<TollGateStatus | null>(null)
const tollgateChecked = ref(false)
const tollgateSaving = ref(false)
const tollgatePrice = ref(10)
const tollgateEnabled = ref(false)
const tollgateUnit = computed(() => {
const ms = tollgate.value?.step_size_ms || 60_000
if (ms === 3_600_000) return 'hour'
if (ms === 60_000) return 'minute'
if (ms === 1000) return 'second'
return `${ms.toLocaleString()} ms`
})
async function loadTollgate() {
try {
const res = await rpcClient.call<{ tollgate?: TollGateStatus }>({ method: 'openwrt.get-status' })
tollgate.value = res.tollgate || null
tollgateEnabled.value = !!res.tollgate?.enabled
tollgatePrice.value = Math.max(1, res.tollgate?.price_per_step || 10)
} catch {
// No gateway configured (or unreachable) the card shows the setup hint.
tollgate.value = null
} finally {
tollgateChecked.value = true
}
}
async function saveTollgate() {
if (tollgatePrice.value < 1) tollgatePrice.value = 1
tollgateSaving.value = true
try {
await rpcClient.call({
method: 'openwrt.provision-tollgate',
params: {
enabled: tollgateEnabled.value,
// Real backend reads price_sats; the mock reads price_per_step
// send both so the same call works against either.
price_sats: tollgatePrice.value,
price_per_step: tollgatePrice.value,
step_size_ms: tollgate.value?.step_size_ms || 60_000,
min_steps: tollgate.value?.min_steps || 1,
},
})
showStatus(
tollgateEnabled.value
? `TollGate WiFi is charging ${tollgatePrice.value} sats per ${tollgateUnit.value}.`
: 'TollGate WiFi is now free.',
false,
)
void loadTollgate()
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save TollGate settings', true)
} finally {
tollgateSaving.value = false
}
}
// Dashboard state
type ProfitSource = 'content_sale' | 'routing_fee' | 'streaming_revenue'
interface ProfitEntry {
source: ProfitSource
amount_sats: number
timestamp: string
description?: string
}
interface ProfitsSummary {
total_sats: number
content_sales_sats: number
routing_fees_sats: number
streaming_revenue_sats?: number
recent?: ProfitEntry[]
}
interface SessionsSummary {
total_active: number
total_revenue_sats: number
revenue_by_service: Record<string, number>
}
const profits = ref<ProfitsSummary | null>(null)
const sessionsSummary = ref<SessionsSummary | null>(null)
const dashboardLoading = ref(true)
async function loadDashboard() {
dashboardLoading.value = true
try {
const [p, s] = await Promise.all([
rpcClient.call<ProfitsSummary>({ method: 'wallet.networking-profits' }).catch(() => null),
rpcClient.call<SessionsSummary>({ method: 'streaming.list-sessions' }).catch(() => null),
])
profits.value = p
sessionsSummary.value = s
} finally {
dashboardLoading.value = false
}
}
function formatSats(n: number | undefined | null): string {
return (n ?? 0).toLocaleString()
}
// 7 daily buckets, oldest newest, one series per earning source. A week of
// daily totals is the sweet spot here: earnings are sparse events (unlike the
// second-by-second system metrics on Monitoring), so finer buckets would just
// draw noise, and a longer window would flatten a new node's first sats.
const DAY_MS = 86_400_000
const WINDOW_DAYS = 7
const dayLabels = computed(() => {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const labels: string[] = []
for (let i = WINDOW_DAYS - 1; i >= 0; i--) {
labels.push(names[new Date(Date.now() - i * DAY_MS).getDay()] as string)
}
return labels
})
function bucketize(source: ProfitSource): number[] {
const buckets = new Array(WINDOW_DAYS).fill(0)
const now = Date.now()
for (const e of profits.value?.recent || []) {
if (e.source !== source) continue
const t = Date.parse(e.timestamp)
if (Number.isNaN(t)) continue
const age = Math.floor((now - t) / DAY_MS)
if (age < 0 || age >= WINDOW_DAYS) continue
buckets[WINDOW_DAYS - 1 - age] += e.amount_sats
}
return buckets
}
const earningsDatasets = computed<ChartDataset[]>(() => [
{ label: 'Streaming', data: bucketize('streaming_revenue'), color: '#f97316' },
{ label: 'Content sales', data: bucketize('content_sale'), color: '#3b82f6' },
{ label: 'Routing fees', data: bucketize('routing_fee'), color: '#a78bfa' },
])
const hasRecentEarnings = computed(() =>
earningsDatasets.value.some((d) => d.data.some((v) => v > 0)),
)
// Revenue-by-service bars, largest first, scaled to the biggest earner.
const serviceBars = computed(() => {
const by = sessionsSummary.value?.revenue_by_service || {}
const entries = Object.entries(by).sort((a, b) => b[1] - a[1])
const max = entries[0]?.[1] || 1
return entries.map(([id, sats]) => ({
id,
name: services.value.find((s) => s.service_id === id)?.name || id,
sats,
pct: Math.max(4, Math.round((sats / max) * 100)),
}))
})
// Chart sizing (same measure-the-card approach as Monitoring)
const chartCard = ref<HTMLElement | null>(null)
const chartWidth = ref(600)
function updateChartWidth() {
chartWidth.value = Math.max(280, (chartCard.value?.clientWidth || 640) - 40)
}
onMounted(() => {
void load()
void loadTollgate()
void loadDashboard()
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateChartWidth)
})
</script>
<template>
<div class="pb-6">
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
<div class="mb-4">
<h1 class="text-3xl font-bold text-white">Networking Profits</h1>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
<!-- Tabs: Dashboard | Configure -->
<div class="flex gap-1 mb-6 border-b border-white/10">
<button
@click="tab = 'dashboard'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Dashboard
</button>
<button
@click="tab = 'configure'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'configure' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Configure
</button>
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
<!-- ============ DASHBOARD ============ -->
<div v-show="tab === 'dashboard'">
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings</div>
<template v-else>
<!-- Stat tiles -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p>
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p>
<p class="text-xs text-white/40">sats, all time</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
<p class="text-xs text-white/40">sats from paid services</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Content sales</p>
<p class="text-2xl font-bold text-blue-400">{{ formatSats(profits?.content_sales_sats) }}</p>
<p class="text-xs text-white/40">sats from ecash sales</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Routing fees</p>
<p class="text-2xl font-bold text-violet-400">{{ formatSats(profits?.routing_fees_sats) }}</p>
<p class="text-xs text-white/40">sats from Lightning</p>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
<!-- Earnings chart -->
<div ref="chartCard" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white/80">Earnings last 7 days</h3>
<div class="flex items-center gap-4">
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
{{ d.label }}
</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
<LineChart
v-if="hasRecentEarnings"
:datasets="earningsDatasets"
:labels="dayLabels"
:width="chartWidth"
:height="200"
/>
<div v-else class="py-10 text-center text-white/40 text-sm">
No earnings in the last 7 days enable a paid service under Configure to start earning.
</div>
</div>
<!-- Sessions + revenue by service -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide mb-1">Active sessions</p>
<p class="text-4xl font-bold text-white mb-1">{{ sessionsSummary?.total_active ?? 0 }}</p>
<p class="text-xs text-white/40">peers currently paying for services</p>
<p class="text-sm text-white/70 mt-4">
Session revenue:
<span class="text-white font-medium">{{ formatSats(sessionsSummary?.total_revenue_sats) }} sats</span>
</p>
</div>
<div class="glass-card p-5 lg:col-span-2">
<h3 class="text-sm font-medium text-white/80 mb-4">Revenue by service</h3>
<div v-if="serviceBars.length === 0" class="py-6 text-center text-white/40 text-sm">
No service revenue yet.
</div>
<div v-else class="space-y-3">
<div v-for="bar in serviceBars" :key="bar.id">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-white/80">{{ bar.name }}</span>
<span class="text-white/50">{{ formatSats(bar.sats) }} sats</span>
</div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-orange-500/70" :style="{ width: bar.pct + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- ============ CONFIGURE ============ -->
<div v-show="tab === 'configure'">
<!-- Intro copy, boxed so it reads as its own thing rather than page dressing -->
<div class="glass-card p-4 mb-6">
<p class="text-sm text-white/70 leading-relaxed">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
<!-- TollGate: paid WiFi on the OpenWrt gateway -->
<div v-if="tollgateChecked" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">TollGate WiFi</h2>
<p class="text-sm text-white/60 mt-0.5">
Sell WiFi access on your OpenWrt gateway visitors pay per {{ tollgateUnit }} in ecash.
</p>
</div>
<div v-if="tollgate?.installed" class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="tollgateEnabled ? 'text-orange-400' : 'text-white/40'">
{{ tollgateEnabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="tollgateEnabled" @update:model-value="(v) => (tollgateEnabled = v)" />
</div>
</div>
<div v-if="tollgate?.installed" class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !tollgateEnabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="tollgatePrice"
type="number"
min="1"
step="1"
:disabled="!tollgateEnabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ tollgateUnit }}</span>
</div>
</div>
<button
@click="saveTollgate"
:disabled="tollgateSaving"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ tollgateSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<div v-else class="flex flex-col sm:flex-row sm:items-center gap-3">
<p class="text-sm text-white/50 flex-1">
TollGate isn't set up yet it needs an OpenWrt gateway paired with this node.
</p>
<button
@click="router.push('/dashboard/server/openwrt')"
class="glass-button px-4 py-2 rounded-lg text-sm shrink-0"
>
Set up gateway
</button>
</div>
</div>
</div>
</div>
+39 -34
View File
@@ -1,27 +1,36 @@
<template>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
<p class="text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<div v-if="visibilityLoading" class="shrink-0">
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="mb-4 shrink-0">
<div class="flex items-center sm:items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white sm:mb-2">{{ t('web5.nodeVisibility') }}</h2>
<!-- Desktop: description beside the icon, as before -->
<p class="hidden sm:block text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<div v-if="visibilityLoading" class="shrink-0">
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<!-- Mobile: description stacked under the icon + title -->
<p class="sm:hidden mt-3 text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<!-- Enable switch -->
@@ -57,16 +66,7 @@
<!-- Discoverable nodes -->
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-white">Discoverable nodes</p>
<button
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
</div>
<p class="text-sm font-medium text-white mb-2">Discoverable nodes</p>
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
Searching relays
</div>
@@ -95,10 +95,15 @@
</div>
</div>
<!-- Warning -->
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
{{ t('web5.discoverableWarning') }}
</p>
<!-- Refresh full-width card action on every screen size -->
<button
v-if="discoverEnabled"
class="mt-4 w-full glass-button rounded-lg py-2.5 text-sm font-medium text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
<PeerRequestModal
:show="requestModalTarget !== null"
+3 -1
View File
@@ -37,7 +37,9 @@ export default defineConfig({
]
},
workbox: {
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
// /packages/ must bypass the SPA fallback — otherwise clicking the
// companion APK download link gets index.html instead of the file.
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//, /^\/packages\//],
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [