frontend: polish app launch and release experience
This commit is contained in:
parent
c393b96da3
commit
1a3d726eac
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
@ -103,6 +103,47 @@ const walletState = {
|
|||||||
}
|
}
|
||||||
function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') }
|
function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') }
|
||||||
|
|
||||||
|
const bitcoinRelayMockState = {
|
||||||
|
settings: {
|
||||||
|
enabled_for_peers: true,
|
||||||
|
allow_peer_requests: true,
|
||||||
|
allow_http: false,
|
||||||
|
allow_https: true,
|
||||||
|
allow_tor: true,
|
||||||
|
selected_peer_pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04',
|
||||||
|
http_endpoint: '',
|
||||||
|
https_endpoint: 'https://shard.tx1138.com/',
|
||||||
|
tor_endpoint: 'http://btc-relay-demoabcdefghijklmnop.onion/',
|
||||||
|
},
|
||||||
|
trusted_nodes: [
|
||||||
|
{
|
||||||
|
pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04',
|
||||||
|
onion: 'trustedalphaabcdefghijklmnop.onion',
|
||||||
|
name: 'Trusted Alpha',
|
||||||
|
relay_approved: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '02f6ab6c88037cd527a92f3a016a7bd18bb2ebd91d5a3efb1161481b5cf7d9ea2a',
|
||||||
|
onion: 'trustedbetabcdefghijklmnopq.onion',
|
||||||
|
name: 'Trusted Beta',
|
||||||
|
relay_approved: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
id: 'relay-demo-incoming',
|
||||||
|
direction: 'incoming',
|
||||||
|
status: 'pending',
|
||||||
|
peer_pubkey: '02f6ab6c88037cd527a92f3a016a7bd18bb2ebd91d5a3efb1161481b5cf7d9ea2a',
|
||||||
|
peer_onion: 'trustedbetabcdefghijklmnopq.onion',
|
||||||
|
peer_name: 'Trusted Beta',
|
||||||
|
message: 'Can I use your node for a Wasabi broadcast?',
|
||||||
|
created_at: new Date(Date.now() - 12 * 60 * 1000).toISOString(),
|
||||||
|
updated_at: new Date(Date.now() - 12 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
// User state (simulated file-based storage)
|
// User state (simulated file-based storage)
|
||||||
let userState = {
|
let userState = {
|
||||||
setupComplete: false,
|
setupComplete: false,
|
||||||
@ -719,6 +760,16 @@ function staticApp({ id, title, version, shortDesc, longDesc, license, state, la
|
|||||||
|
|
||||||
// Static dev apps (always shown in My Apps when using mock backend)
|
// Static dev apps (always shown in My Apps when using mock backend)
|
||||||
const staticDevApps = {
|
const staticDevApps = {
|
||||||
|
'bitcoin-knots': staticApp({
|
||||||
|
id: 'bitcoin-knots',
|
||||||
|
title: 'Bitcoin Knots',
|
||||||
|
version: '27.1',
|
||||||
|
shortDesc: 'Full Bitcoin node',
|
||||||
|
longDesc: 'Validate and relay Bitcoin blocks and transactions with the Archipelago custom node UI.',
|
||||||
|
state: 'running',
|
||||||
|
lanPort: 8334,
|
||||||
|
icon: '/assets/img/app-icons/bitcoin-knots.webp',
|
||||||
|
}),
|
||||||
bitcoin: staticApp({
|
bitcoin: staticApp({
|
||||||
id: 'bitcoin',
|
id: 'bitcoin',
|
||||||
title: 'Bitcoin Core',
|
title: 'Bitcoin Core',
|
||||||
@ -765,6 +816,16 @@ const staticDevApps = {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
lanPort: null,
|
lanPort: null,
|
||||||
}),
|
}),
|
||||||
|
meshtastic: staticApp({
|
||||||
|
id: 'meshtastic',
|
||||||
|
title: 'Meshtastic',
|
||||||
|
version: '2-daily-alpine',
|
||||||
|
shortDesc: 'LoRa mesh networking',
|
||||||
|
longDesc: 'Open-source mesh networking for LoRa radios. Create decentralized communication networks.',
|
||||||
|
state: 'running',
|
||||||
|
lanPort: 4403,
|
||||||
|
icon: '/assets/img/app-icons/meshcore.svg',
|
||||||
|
}),
|
||||||
filebrowser: staticApp({
|
filebrowser: staticApp({
|
||||||
id: 'filebrowser',
|
id: 'filebrowser',
|
||||||
title: 'File Browser',
|
title: 'File Browser',
|
||||||
@ -826,6 +887,200 @@ app.options('/rpc/v1', (req, res) => {
|
|||||||
res.status(200).end()
|
res.status(200).end()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
const uiPort = process.env.VITE_DEV_SERVER_PORT || '8102'
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.type('html')
|
||||||
|
.send(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="0; url=http://localhost:${uiPort}/">
|
||||||
|
<title>Archipelago dev backend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>This is the mock JSON-RPC backend. Open the dashboard at
|
||||||
|
<a href="http://localhost:${uiPort}/">http://localhost:${uiPort}/</a>.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/rpc/v1', (_req, res) => {
|
||||||
|
const uiPort = process.env.VITE_DEV_SERVER_PORT || '8102'
|
||||||
|
res
|
||||||
|
.status(405)
|
||||||
|
.type('text/plain')
|
||||||
|
.send(`JSON-RPC is available at /rpc/v1 for POST requests only.\nOpen the dashboard at http://localhost:${uiPort}/.\n`)
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockBitcoinBlockchainInfo() {
|
||||||
|
return {
|
||||||
|
chain: 'main',
|
||||||
|
blocks: 902418,
|
||||||
|
headers: 902418,
|
||||||
|
bestblockhash: randomHex(32),
|
||||||
|
difficulty: 126_984_812_384_099.3,
|
||||||
|
verificationprogress: 0.999998,
|
||||||
|
initialblockdownload: false,
|
||||||
|
size_on_disk: 678_000_000_000,
|
||||||
|
pruned: false,
|
||||||
|
chainwork: '00000000000000000000000000000000000000008d4b42d8b9b0000000000000',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockBitcoinNetworkInfo() {
|
||||||
|
return {
|
||||||
|
version: 270100,
|
||||||
|
subversion: '/Satoshi:27.1/Knots:20240513/',
|
||||||
|
protocolversion: 70016,
|
||||||
|
localservices: '0000000000000409',
|
||||||
|
localrelay: true,
|
||||||
|
timeoffset: 0,
|
||||||
|
networkactive: true,
|
||||||
|
connections: 18,
|
||||||
|
networks: [
|
||||||
|
{ name: 'ipv4', limited: false, reachable: true, proxy: '', proxy_randomize_credentials: false },
|
||||||
|
{ name: 'onion', limited: false, reachable: true, proxy: '127.0.0.1:9050', proxy_randomize_credentials: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitcoinRelayStatusPayload() {
|
||||||
|
return {
|
||||||
|
settings: bitcoinRelayMockState.settings,
|
||||||
|
trusted_nodes: bitcoinRelayMockState.trusted_nodes,
|
||||||
|
requests: bitcoinRelayMockState.requests,
|
||||||
|
local_node: {
|
||||||
|
synced: true,
|
||||||
|
blocks: 902418,
|
||||||
|
headers: 902418,
|
||||||
|
chain: 'main',
|
||||||
|
status_ok: true,
|
||||||
|
status_stale: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
username: 'txrelay',
|
||||||
|
available: true,
|
||||||
|
password_available: true,
|
||||||
|
rpcauth_available: true,
|
||||||
|
client_env_available: true,
|
||||||
|
client_env_path: '/var/lib/archipelago/secrets/bitcoin-rpc-txrelay-client.env',
|
||||||
|
restart_hint: 'If this was just generated, restart Bitcoin Core/Knots so bitcoind loads the txrelay rpcauth whitelist.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/app/bitcoin-ui/', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const html = await fs.readFile(path.join(__dirname, '..', 'docker', 'bitcoin-ui', 'index.html'), 'utf8')
|
||||||
|
res.type('html').send(html)
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).type('text/plain').send(`Unable to load Bitcoin UI mock: ${error.message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
stale: false,
|
||||||
|
updated_at_ms: Date.now(),
|
||||||
|
error: null,
|
||||||
|
blockchain_info: mockBitcoinBlockchainInfo(),
|
||||||
|
network_info: mockBitcoinNetworkInfo(),
|
||||||
|
index_info: {
|
||||||
|
txindex: { synced: true, best_block_height: 902418 },
|
||||||
|
blockfilterindex: { synced: true, best_block_height: 902418 },
|
||||||
|
},
|
||||||
|
zmq_notifications: [
|
||||||
|
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
|
||||||
|
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/app/bitcoin-ui/bitcoin-rpc/', (req, res) => {
|
||||||
|
const { id = 'bitcoin-ui', method } = req.body || {}
|
||||||
|
const results = {
|
||||||
|
getblockchaininfo: mockBitcoinBlockchainInfo(),
|
||||||
|
getnetworkinfo: mockBitcoinNetworkInfo(),
|
||||||
|
getindexinfo: {
|
||||||
|
txindex: { synced: true, best_block_height: 902418 },
|
||||||
|
blockfilterindex: { synced: true, best_block_height: 902418 },
|
||||||
|
},
|
||||||
|
getzmqnotifications: [
|
||||||
|
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
|
||||||
|
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
|
||||||
|
],
|
||||||
|
getmempoolinfo: { loaded: true, size: 18452, bytes: 62400000, usage: 143000000, maxmempool: 300000000 },
|
||||||
|
getpeerinfo: Array.from({ length: 18 }, (_, index) => ({ id: index, addr: `198.51.100.${index + 10}:8333`, inbound: index % 3 === 0 })),
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(results, method)) {
|
||||||
|
return res.json({ id, result: null, error: { code: -32601, message: `Method not found: ${method}` } })
|
||||||
|
}
|
||||||
|
res.json({ id, result: results[method], error: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/app/bitcoin-ui/rpc/v1', (req, res) => {
|
||||||
|
const { method, params = {} } = req.body || {}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
switch (method) {
|
||||||
|
case 'bitcoin.relay-status':
|
||||||
|
return res.json({ result: bitcoinRelayStatusPayload(), error: null })
|
||||||
|
case 'bitcoin.relay-update-settings':
|
||||||
|
bitcoinRelayMockState.settings = {
|
||||||
|
...bitcoinRelayMockState.settings,
|
||||||
|
...params,
|
||||||
|
}
|
||||||
|
return res.json({ result: bitcoinRelayStatusPayload(), error: null })
|
||||||
|
case 'bitcoin.relay-request-peer': {
|
||||||
|
const peer = bitcoinRelayMockState.trusted_nodes.find(p => p.pubkey === params.peer_pubkey)
|
||||||
|
if (!peer) return res.status(400).json({ error: { message: 'Peer is not in trusted nodes' } })
|
||||||
|
const request = {
|
||||||
|
id: `relay-demo-${Date.now()}`,
|
||||||
|
direction: 'outbound',
|
||||||
|
status: 'pending',
|
||||||
|
peer_pubkey: peer.pubkey,
|
||||||
|
peer_onion: peer.onion,
|
||||||
|
peer_name: peer.name,
|
||||||
|
message: params.message || '',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
bitcoinRelayMockState.requests.push(request)
|
||||||
|
return res.json({ result: { ok: true, request_id: request.id }, error: null })
|
||||||
|
}
|
||||||
|
case 'bitcoin.relay-approve-request':
|
||||||
|
case 'bitcoin.relay-reject-request': {
|
||||||
|
const requestId = params.id || params.request_id
|
||||||
|
const request = bitcoinRelayMockState.requests.find(r => r.id === requestId)
|
||||||
|
if (!request) return res.status(404).json({ error: { message: `Request not found: ${requestId}` } })
|
||||||
|
request.status = method.endsWith('approve-request') ? 'approved' : 'rejected'
|
||||||
|
request.updated_at = now
|
||||||
|
if (request.status === 'approved') {
|
||||||
|
request.approved_endpoint = bitcoinRelayMockState.settings.https_endpoint || bitcoinRelayMockState.settings.tor_endpoint || bitcoinRelayMockState.settings.http_endpoint
|
||||||
|
request.credential_secret_path = '/var/lib/archipelago/secrets/bitcoin-relay-peer-demo.env'
|
||||||
|
}
|
||||||
|
return res.json({ result: { ok: true, request_id: request.id }, error: null })
|
||||||
|
}
|
||||||
|
case 'bitcoin.relay-create-tor-service':
|
||||||
|
bitcoinRelayMockState.settings.allow_tor = true
|
||||||
|
bitcoinRelayMockState.settings.tor_endpoint = 'http://btc-relay-demoabcdefghijklmnop.onion/'
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
created: true,
|
||||||
|
name: 'bitcoin-rpc',
|
||||||
|
onion_address: 'btc-relay-demoabcdefghijklmnop.onion',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return res.status(404).json({ error: { message: `Unknown mock Bitcoin UI RPC method: ${method}` } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// RPC endpoint
|
// RPC endpoint
|
||||||
app.post('/rpc/v1', (req, res) => {
|
app.post('/rpc/v1', (req, res) => {
|
||||||
const { method, params } = req.body
|
const { method, params } = req.body
|
||||||
|
|||||||
BIN
neode-ui/public/packages/archipelago-companion.apk.zip
Normal file
BIN
neode-ui/public/packages/archipelago-companion.apk.zip
Normal file
Binary file not shown.
@ -89,6 +89,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useScreensaverStore } from '@/stores/screensaver'
|
import { useScreensaverStore } from '@/stores/screensaver'
|
||||||
import { useUIModeStore } from '@/stores/uiMode'
|
import { useUIModeStore } from '@/stores/uiMode'
|
||||||
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
|
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
|
||||||
|
import { shouldShowIntroSplash } from '@/utils/introSplash'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const screensaverStore = useScreensaverStore()
|
const screensaverStore = useScreensaverStore()
|
||||||
@ -176,6 +177,129 @@ const route = useRoute()
|
|||||||
// Start with splash hidden — onMounted decides whether to show it
|
// Start with splash hidden — onMounted decides whether to show it
|
||||||
const showSplash = ref(false)
|
const showSplash = ref(false)
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
|
let modalOverlayObserver: MutationObserver | null = null
|
||||||
|
let lockedScrollY = 0
|
||||||
|
let previousBodyStyles: Partial<CSSStyleDeclaration> = {}
|
||||||
|
let bodyLockedForModal = false
|
||||||
|
let modalTouchY: number | null = null
|
||||||
|
|
||||||
|
function hasBlockingOverlay() {
|
||||||
|
if (typeof document === 'undefined') return false
|
||||||
|
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
|
||||||
|
.some((el) => {
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return style.display !== 'none'
|
||||||
|
&& style.visibility !== 'hidden'
|
||||||
|
&& style.pointerEvents !== 'none'
|
||||||
|
&& rect.width > 0
|
||||||
|
&& rect.height > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleBlockingOverlays() {
|
||||||
|
if (typeof document === 'undefined') return []
|
||||||
|
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
|
||||||
|
.filter((el) => {
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return style.display !== 'none'
|
||||||
|
&& style.visibility !== 'hidden'
|
||||||
|
&& style.pointerEvents !== 'none'
|
||||||
|
&& rect.width > 0
|
||||||
|
&& rect.height > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestOverlay(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return null
|
||||||
|
return visibleBlockingOverlays().find((overlay) => overlay.contains(target)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function canScrollInsideOverlay(target: EventTarget | null, overlay: HTMLElement, deltaY: number) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false
|
||||||
|
let el: HTMLElement | null = target
|
||||||
|
while (el && overlay.contains(el)) {
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
const canScrollY = /(auto|scroll)/.test(style.overflowY)
|
||||||
|
&& el.scrollHeight > el.clientHeight
|
||||||
|
if (canScrollY) {
|
||||||
|
if (deltaY < 0 && el.scrollTop > 0) return true
|
||||||
|
if (deltaY > 0 && el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true
|
||||||
|
}
|
||||||
|
if (el === overlay) break
|
||||||
|
el = el.parentElement
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function containModalWheel(ev: WheelEvent) {
|
||||||
|
if (!bodyLockedForModal) return
|
||||||
|
const overlay = closestOverlay(ev.target)
|
||||||
|
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, ev.deltaY)) {
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function containModalTouchStart(ev: TouchEvent) {
|
||||||
|
modalTouchY = ev.touches[0]?.clientY ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function containModalTouchMove(ev: TouchEvent) {
|
||||||
|
if (!bodyLockedForModal) return
|
||||||
|
const currentY = ev.touches[0]?.clientY
|
||||||
|
if (currentY === undefined || modalTouchY === null) {
|
||||||
|
ev.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deltaY = modalTouchY - currentY
|
||||||
|
modalTouchY = currentY
|
||||||
|
const overlay = closestOverlay(ev.target)
|
||||||
|
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, deltaY)) {
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockBodyForModal() {
|
||||||
|
if (bodyLockedForModal || typeof document === 'undefined') return
|
||||||
|
lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0
|
||||||
|
previousBodyStyles = {
|
||||||
|
position: document.body.style.position,
|
||||||
|
top: document.body.style.top,
|
||||||
|
left: document.body.style.left,
|
||||||
|
right: document.body.style.right,
|
||||||
|
width: document.body.style.width,
|
||||||
|
overflow: document.body.style.overflow,
|
||||||
|
}
|
||||||
|
document.body.style.position = 'fixed'
|
||||||
|
document.body.style.top = `-${lockedScrollY}px`
|
||||||
|
document.body.style.left = '0'
|
||||||
|
document.body.style.right = '0'
|
||||||
|
document.body.style.width = '100%'
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.documentElement.classList.add('modal-scroll-locked')
|
||||||
|
bodyLockedForModal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockBodyForModal() {
|
||||||
|
if (!bodyLockedForModal || typeof document === 'undefined') return
|
||||||
|
document.body.style.position = previousBodyStyles.position || ''
|
||||||
|
document.body.style.top = previousBodyStyles.top || ''
|
||||||
|
document.body.style.left = previousBodyStyles.left || ''
|
||||||
|
document.body.style.right = previousBodyStyles.right || ''
|
||||||
|
document.body.style.width = previousBodyStyles.width || ''
|
||||||
|
document.body.style.overflow = previousBodyStyles.overflow || ''
|
||||||
|
document.documentElement.classList.remove('modal-scroll-locked')
|
||||||
|
window.scrollTo(0, lockedScrollY)
|
||||||
|
previousBodyStyles = {}
|
||||||
|
bodyLockedForModal = false
|
||||||
|
modalTouchY = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncModalBodyLock() {
|
||||||
|
if (hasBlockingOverlay()) lockBodyForModal()
|
||||||
|
else unlockBodyForModal()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if splash screen should be shown
|
* Determine if splash screen should be shown
|
||||||
@ -213,18 +337,51 @@ onMounted(async () => {
|
|||||||
window.addEventListener('keydown', onUserActivity)
|
window.addEventListener('keydown', onUserActivity)
|
||||||
window.addEventListener('touchstart', onUserActivity)
|
window.addEventListener('touchstart', onUserActivity)
|
||||||
window.addEventListener('message', onShareToMeshMessage)
|
window.addEventListener('message', onShareToMeshMessage)
|
||||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
|
||||||
const isDirectRoute = route.path !== '/'
|
document.addEventListener('touchstart', containModalTouchStart, { capture: true, passive: true })
|
||||||
|
document.addEventListener('touchmove', containModalTouchMove, { capture: true, passive: false })
|
||||||
|
modalOverlayObserver = new MutationObserver(() => {
|
||||||
|
requestAnimationFrame(syncModalBodyLock)
|
||||||
|
})
|
||||||
|
modalOverlayObserver.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style'],
|
||||||
|
})
|
||||||
|
syncModalBodyLock()
|
||||||
|
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
||||||
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
||||||
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot)
|
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||||
|
const splashCandidate = !seenIntro
|
||||||
|
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||||
|
|
||||||
if (fromBoot && !seenIntro) {
|
if (splashCandidate && onboardingComplete !== true) {
|
||||||
|
try {
|
||||||
|
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||||
|
onboardingComplete = await checkOnboardingStatus()
|
||||||
|
} catch {
|
||||||
|
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenIntro && onboardingComplete === true) {
|
||||||
|
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
|
||||||
|
seenIntro = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot, 'onboardingComplete:', onboardingComplete)
|
||||||
|
|
||||||
|
if (shouldShowIntroSplash({
|
||||||
|
seenIntro,
|
||||||
|
routePath: route.path,
|
||||||
|
fromBoot,
|
||||||
|
devMode: import.meta.env.VITE_DEV_MODE,
|
||||||
|
onboardingComplete,
|
||||||
|
})) {
|
||||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||||
showSplash.value = true
|
showSplash.value = true
|
||||||
} else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') {
|
|
||||||
// Normal first visit (not boot mode) — show splash intro
|
|
||||||
showSplash.value = true
|
|
||||||
} else {
|
} else {
|
||||||
// Already seen intro, direct route, or boot mode (boot screen handles intro)
|
// Already seen intro, direct route, or boot mode (boot screen handles intro)
|
||||||
// Set isReady BEFORE hiding splash to prevent flash of partial content
|
// Set isReady BEFORE hiding splash to prevent flash of partial content
|
||||||
@ -243,6 +400,12 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('keydown', onUserActivity)
|
window.removeEventListener('keydown', onUserActivity)
|
||||||
window.removeEventListener('touchstart', onUserActivity)
|
window.removeEventListener('touchstart', onUserActivity)
|
||||||
window.removeEventListener('message', onShareToMeshMessage)
|
window.removeEventListener('message', onShareToMeshMessage)
|
||||||
|
document.removeEventListener('wheel', containModalWheel, { capture: true })
|
||||||
|
document.removeEventListener('touchstart', containModalTouchStart, { capture: true })
|
||||||
|
document.removeEventListener('touchmove', containModalTouchMove, { capture: true })
|
||||||
|
modalOverlayObserver?.disconnect()
|
||||||
|
modalOverlayObserver = null
|
||||||
|
unlockBodyForModal()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -450,6 +450,12 @@ describe('RPCClient convenience methods', () => {
|
|||||||
expect(getLastMethod()).toBe('package.uninstall')
|
expect(getLastMethod()).toBe('package.uninstall')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uninstallPackage forwards preserve_data when requested', async () => {
|
||||||
|
mockSuccess(undefined)
|
||||||
|
await rpcClient.uninstallPackage('btc', { preserveData: true })
|
||||||
|
expect(getLastParams()).toEqual({ id: 'btc', preserve_data: true })
|
||||||
|
})
|
||||||
|
|
||||||
it('startPackage calls package.start', async () => {
|
it('startPackage calls package.start', async () => {
|
||||||
mockSuccess(undefined)
|
mockSuccess(undefined)
|
||||||
await rpcClient.startPackage('btc')
|
await rpcClient.startPackage('btc')
|
||||||
|
|||||||
@ -201,7 +201,7 @@ class RPCClient {
|
|||||||
currentPassword: string
|
currentPassword: string
|
||||||
newPassword: string
|
newPassword: string
|
||||||
alsoChangeSsh?: boolean
|
alsoChangeSsh?: boolean
|
||||||
}): Promise<{ success: boolean }> {
|
}): Promise<{ success: boolean; ssh_updated?: boolean; ssh_error?: string | null }> {
|
||||||
return this.call({
|
return this.call({
|
||||||
method: 'auth.changePassword',
|
method: 'auth.changePassword',
|
||||||
params: {
|
params: {
|
||||||
@ -536,14 +536,17 @@ class RPCClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
|
async uninstallPackage(
|
||||||
|
id: string,
|
||||||
|
options: { preserveData?: boolean } = {},
|
||||||
|
): Promise<{ status: string; package_id: string }> {
|
||||||
// Backend is async — returns { status: 'removing' } immediately after
|
// Backend is async — returns { status: 'removing' } immediately after
|
||||||
// flipping state. Graceful stop (up to 600s for bitcoin) and data wipe
|
// flipping state. Graceful stop (up to 600s for bitcoin) and data wipe
|
||||||
// (up to minutes for large chainstate) run in a background task.
|
// (up to minutes for large chainstate) run in a background task.
|
||||||
// Progress shown via uninstall_stage field on the package entry.
|
// Progress shown via uninstall_stage field on the package entry.
|
||||||
return this.call({
|
return this.call({
|
||||||
method: 'package.uninstall',
|
method: 'package.uninstall',
|
||||||
params: { id },
|
params: { id, ...(options.preserveData !== undefined ? { preserve_data: options.preserveData } : {}) },
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@ -65,6 +66,7 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useModalKeyboard(modalRef, computed(() => props.show), close)
|
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||||
|
useBodyScrollLock(computed(() => props.show))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -11,10 +11,18 @@
|
|||||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<!-- Icon -->
|
<button
|
||||||
<div class="flex justify-center mb-3">
|
type="button"
|
||||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
|
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
aria-label="Close companion modal"
|
||||||
|
@click="dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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">
|
||||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||||
@ -22,38 +30,44 @@
|
|||||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||||
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
|
<p class="text-sm text-white/60 leading-relaxed">
|
||||||
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
|
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||||
Control your node from another device. Install the
|
</p>
|
||||||
<span class="text-orange-400 font-medium">Archipelago</span>
|
|
||||||
companion app on your phone, connect to the same network, and use it as a
|
|
||||||
gamepad or keyboard.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 text-xs text-white/40">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">1</span>
|
|
||||||
<span>Install the APK on your phone</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">2</span>
|
|
||||||
<span>Enter your node address and password</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">3</span>
|
|
||||||
<span>Use D-pad & buttons or keyboard to control apps</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="mb-4">
|
||||||
class="mt-4 w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30
|
<a
|
||||||
text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors"
|
:href="companionDownloadUrl"
|
||||||
@click="dismiss"
|
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Download companion app
|
||||||
|
</a>
|
||||||
|
<div class="hidden md:flex justify-center">
|
||||||
|
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="qrDataUrl"
|
||||||
|
:src="qrDataUrl"
|
||||||
|
alt="Companion 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>
|
||||||
|
|
||||||
|
<a
|
||||||
|
:href="companionDownloadUrl"
|
||||||
|
class="hidden md:block 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 text-center"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Got it
|
Download companion app
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@ -61,11 +75,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import * as QRCode from 'qrcode'
|
||||||
|
|
||||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||||
|
const DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
|
const qrDataUrl = ref('')
|
||||||
|
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
try {
|
try {
|
||||||
@ -78,6 +96,19 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(visible, async (isVisible) => {
|
||||||
|
if (!isVisible) return
|
||||||
|
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
||||||
|
width: 112,
|
||||||
|
margin: 1,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
color: {
|
||||||
|
dark: '#111111',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, { immediate: true, flush: 'post' })
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const sharingLocation = ref(false)
|
|||||||
const locationSource = ref<'browser' | 'device'>('browser')
|
const locationSource = ref<'browser' | 'device'>('browser')
|
||||||
const locationError = ref('')
|
const locationError = ref('')
|
||||||
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
|
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
|
||||||
|
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
|
||||||
let geoWatchId: number | null = null
|
let geoWatchId: number | null = null
|
||||||
|
|
||||||
function toggleLocationSharing() {
|
function toggleLocationSharing() {
|
||||||
@ -337,7 +338,7 @@ onUnmounted(() => {
|
|||||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||||
<circle cx="12" cy="10" r="3" />
|
<circle cx="12" cy="10" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Enable location sharing to see nodes on the map</span>
|
<span>{{ locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location sharing overlay -->
|
<!-- Location sharing overlay -->
|
||||||
@ -373,7 +374,9 @@ onUnmounted(() => {
|
|||||||
>Mesh Radio GPS</button>
|
>Mesh Radio GPS</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="locationError" class="mesh-map-location-error">{{ locationError }}</div>
|
<div v-if="locationError" class="mesh-map-location-error">
|
||||||
|
{{ locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="mb-3 text-center">
|
<div v-else class="mb-3 text-center">
|
||||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||||
|
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ import { ref, nextTick } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@ -124,6 +126,9 @@ async function receive() {
|
|||||||
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
|
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
|
||||||
} else if (receiveMethod.value === 'onchain') {
|
} else if (receiveMethod.value === 'onchain') {
|
||||||
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
|
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
|
||||||
|
if (!res.address) {
|
||||||
|
throw new Error('LND did not return a Bitcoin address')
|
||||||
|
}
|
||||||
onchainAddress.value = res.address
|
onchainAddress.value = res.address
|
||||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||||
} else {
|
} else {
|
||||||
@ -136,7 +141,9 @@ async function receive() {
|
|||||||
emit('received')
|
emit('received')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed'
|
error.value = receiveMethod.value === 'onchain'
|
||||||
|
? explainReceiveAddressFailure(err)
|
||||||
|
: err instanceof Error ? err.message : 'Failed'
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,14 +93,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- AI Assistant placeholder -->
|
|
||||||
<div class="p-2 border-t border-white/10">
|
|
||||||
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
|
|
||||||
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
|
|
||||||
Coming soon — ask questions about your node, apps, and Bitcoin.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
24
neode-ui/src/components/__tests__/BaseModal.test.ts
Normal file
24
neode-ui/src/components/__tests__/BaseModal.test.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import BaseModal from '../BaseModal.vue'
|
||||||
|
|
||||||
|
describe('BaseModal', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks page scroll while open and restores it when closed', async () => {
|
||||||
|
const wrapper = mount(BaseModal, {
|
||||||
|
props: { show: true, title: 'Test modal' },
|
||||||
|
slots: { default: '<p>Modal content</p>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
72
neode-ui/src/components/__tests__/MeshMap.test.ts
Normal file
72
neode-ui/src/components/__tests__/MeshMap.test.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import MeshMap from '../MeshMap.vue'
|
||||||
|
|
||||||
|
const meshState = vi.hoisted(() => ({
|
||||||
|
nodePositions: new Map(),
|
||||||
|
peers: [],
|
||||||
|
status: null,
|
||||||
|
deadmanStatus: null,
|
||||||
|
updateSelfPosition: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/mesh', () => ({
|
||||||
|
useMeshStore: () => meshState,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('leaflet', () => ({
|
||||||
|
default: {
|
||||||
|
map: vi.fn(() => ({
|
||||||
|
invalidateSize: vi.fn(),
|
||||||
|
fitBounds: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
})),
|
||||||
|
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||||
|
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
|
||||||
|
divIcon: vi.fn((opts) => opts),
|
||||||
|
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
|
||||||
|
polyline: vi.fn(() => ({})),
|
||||||
|
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('MeshMap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
meshState.nodePositions.clear()
|
||||||
|
meshState.peers = []
|
||||||
|
meshState.status = null
|
||||||
|
meshState.deadmanStatus = null
|
||||||
|
meshState.updateSelfPosition.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats denied browser location as optional for peer positions', async () => {
|
||||||
|
let errorHandler!: (error: { code: number; message: string }) => void
|
||||||
|
const watchPosition = vi.fn((_success, error) => {
|
||||||
|
errorHandler = error
|
||||||
|
return 7
|
||||||
|
})
|
||||||
|
const clearWatch = vi.fn()
|
||||||
|
const resizeObserver = vi.fn(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: { watchPosition, clearWatch },
|
||||||
|
})
|
||||||
|
vi.stubGlobal('ResizeObserver', resizeObserver)
|
||||||
|
|
||||||
|
const wrapper = mount(MeshMap)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
|
||||||
|
|
||||||
|
await wrapper.get('[role="switch"]').trigger('click')
|
||||||
|
errorHandler({ code: 1, message: 'denied' })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
|
||||||
|
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
|
||||||
|
expect(wrapper.text()).not.toContain('location sharing is required')
|
||||||
|
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -94,4 +94,20 @@ describe('useMarketplaceApp', () => {
|
|||||||
const app = getCurrentApp()
|
const app = getCurrentApp()
|
||||||
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves real screenshot metadata', () => {
|
||||||
|
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||||
|
setCurrentApp({
|
||||||
|
id: 'test',
|
||||||
|
screenshots: [
|
||||||
|
'/screenshots/test-dashboard.png',
|
||||||
|
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getCurrentApp()!.screenshots).toEqual([
|
||||||
|
'/screenshots/test-dashboard.png',
|
||||||
|
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -77,10 +77,10 @@ describe('useOnboarding', () => {
|
|||||||
localStorage.setItem('neode_onboarding_complete', '1')
|
localStorage.setItem('neode_onboarding_complete', '1')
|
||||||
|
|
||||||
const promise = isOnboardingComplete()
|
const promise = isOnboardingComplete()
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
await vi.advanceTimersByTimeAsync(8000)
|
||||||
const result = await promise
|
const result = await promise
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
}, 10000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('completeOnboarding', () => {
|
describe('completeOnboarding', () => {
|
||||||
|
|||||||
37
neode-ui/src/composables/useBodyScrollLock.ts
Normal file
37
neode-ui/src/composables/useBodyScrollLock.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { onBeforeUnmount, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
|
let activeLocks = 0
|
||||||
|
let previousOverflow = ''
|
||||||
|
|
||||||
|
function lock() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (activeLocks === 0) {
|
||||||
|
previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
activeLocks += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlock() {
|
||||||
|
if (typeof document === 'undefined' || activeLocks === 0) return
|
||||||
|
activeLocks -= 1
|
||||||
|
if (activeLocks === 0) {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
previousOverflow = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBodyScrollLock(active: Ref<boolean>) {
|
||||||
|
watch(
|
||||||
|
active,
|
||||||
|
(isActive, wasActive) => {
|
||||||
|
if (isActive && !wasActive) lock()
|
||||||
|
if (!isActive && wasActive) unlock()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (active.value) unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
42
neode-ui/src/composables/useCollapsingHeaderTabs.ts
Normal file
42
neode-ui/src/composables/useCollapsingHeaderTabs.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export function useCollapsingHeaderTabs(
|
||||||
|
headerRef: Ref<HTMLElement | null>,
|
||||||
|
primaryRef: Ref<HTMLElement | null>,
|
||||||
|
tabsProbeRef: Ref<HTMLElement | null>,
|
||||||
|
minSearchWidth = 176
|
||||||
|
) {
|
||||||
|
const collapsed = ref(false)
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
const header = headerRef.value
|
||||||
|
const probe = tabsProbeRef.value
|
||||||
|
if (!header || !probe) return
|
||||||
|
|
||||||
|
const primaryWidth = primaryRef.value?.getBoundingClientRect().width ?? 0
|
||||||
|
const tabsWidth = probe.getBoundingClientRect().width
|
||||||
|
const gapWidth = 48
|
||||||
|
collapsed.value = primaryWidth + tabsWidth + minSearchWidth + gapWidth > header.clientWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMeasure() {
|
||||||
|
nextTick(() => requestAnimationFrame(measure))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scheduleMeasure()
|
||||||
|
resizeObserver = new ResizeObserver(scheduleMeasure)
|
||||||
|
if (headerRef.value) resizeObserver.observe(headerRef.value)
|
||||||
|
if (primaryRef.value) resizeObserver.observe(primaryRef.value)
|
||||||
|
if (tabsProbeRef.value) resizeObserver.observe(tabsProbeRef.value)
|
||||||
|
window.addEventListener('resize', scheduleMeasure)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', scheduleMeasure)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { collapsed, measure: scheduleMeasure }
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ export interface MarketplaceAppInfo {
|
|||||||
dockerImage: string
|
dockerImage: string
|
||||||
/** External web URL for iframe-based web apps (no container needed) */
|
/** External web URL for iframe-based web apps (no container needed) */
|
||||||
webUrl?: string
|
webUrl?: string
|
||||||
|
screenshots?: AppScreenshot[]
|
||||||
containerConfig?: {
|
containerConfig?: {
|
||||||
ports?: string[]
|
ports?: string[]
|
||||||
volumes?: string[]
|
volumes?: string[]
|
||||||
@ -25,6 +26,11 @@ export interface MarketplaceAppInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppScreenshot = string | {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Simple in-memory store for the current marketplace app
|
// Simple in-memory store for the current marketplace app
|
||||||
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
||||||
|
|
||||||
@ -46,6 +52,7 @@ export function useMarketplaceApp() {
|
|||||||
s9pkUrl: app.s9pkUrl ?? '',
|
s9pkUrl: app.s9pkUrl ?? '',
|
||||||
dockerImage: app.dockerImage ?? '',
|
dockerImage: app.dockerImage ?? '',
|
||||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||||
|
screenshots: Array.isArray(app.screenshots) ? app.screenshots : undefined,
|
||||||
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
|
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
id: 'configure-store',
|
id: 'configure-store',
|
||||||
title: 'Set Up Your Store',
|
title: 'Set Up Your Store',
|
||||||
description: 'Create your store, set your currency, and customize your payment page. BTCPay will open so you can configure everything.',
|
description: 'Create your store, set your currency, and customize your payment page. BTCPay will open so you can configure everything.',
|
||||||
|
appId: 'btcpay-server',
|
||||||
action: 'configure',
|
action: 'configure',
|
||||||
isAutomatic: false,
|
isAutomatic: false,
|
||||||
},
|
},
|
||||||
@ -72,6 +73,7 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
id: 'open-channel',
|
id: 'open-channel',
|
||||||
title: 'Open a Lightning Channel',
|
title: 'Open a Lightning Channel',
|
||||||
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
|
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
|
||||||
|
appId: 'lnd',
|
||||||
action: 'configure',
|
action: 'configure',
|
||||||
isAutomatic: false,
|
isAutomatic: false,
|
||||||
},
|
},
|
||||||
@ -99,6 +101,7 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
id: 'configure-filebrowser',
|
id: 'configure-filebrowser',
|
||||||
title: 'Log In',
|
title: 'Log In',
|
||||||
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
|
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
|
||||||
|
appId: 'filebrowser',
|
||||||
action: 'configure',
|
action: 'configure',
|
||||||
isAutomatic: false,
|
isAutomatic: false,
|
||||||
},
|
},
|
||||||
@ -126,6 +129,7 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
id: 'configure-nextcloud',
|
id: 'configure-nextcloud',
|
||||||
title: 'Set Up Your Cloud',
|
title: 'Set Up Your Cloud',
|
||||||
description: 'Create your admin account and configure storage. Nextcloud will open for you to complete setup.',
|
description: 'Create your admin account and configure storage. Nextcloud will open for you to complete setup.',
|
||||||
|
appId: 'nextcloud',
|
||||||
action: 'configure',
|
action: 'configure',
|
||||||
isAutomatic: false,
|
isAutomatic: false,
|
||||||
},
|
},
|
||||||
@ -168,6 +172,7 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
id: 'open-channels',
|
id: 'open-channels',
|
||||||
title: 'Open Payment Channels',
|
title: 'Open Payment Channels',
|
||||||
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
||||||
|
appId: 'lnd',
|
||||||
action: 'configure',
|
action: 'configure',
|
||||||
isAutomatic: false,
|
isAutomatic: false,
|
||||||
},
|
},
|
||||||
@ -210,6 +215,7 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
id: 'configure-guardian',
|
id: 'configure-guardian',
|
||||||
title: 'Set Up Guardian UI',
|
title: 'Set Up Guardian UI',
|
||||||
description: 'Open the Guardian UI (port 8175) to configure your federation name, set the guardian threshold, and initialize the mint.',
|
description: 'Open the Guardian UI (port 8175) to configure your federation name, set the guardian threshold, and initialize the mint.',
|
||||||
|
appId: 'fedimint',
|
||||||
action: 'configure',
|
action: 'configure',
|
||||||
isAutomatic: false,
|
isAutomatic: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -112,10 +112,12 @@
|
|||||||
"setupTab": "Setup",
|
"setupTab": "Setup",
|
||||||
"myApps": "My Apps",
|
"myApps": "My Apps",
|
||||||
"myAppsDesc": "Manage your installed applications",
|
"myAppsDesc": "Manage your installed applications",
|
||||||
|
"recommendedApps": "Recommended Apps",
|
||||||
|
"recommendedAppsDesc": "Add useful App Store apps to your Home screen",
|
||||||
"cloud": "Cloud",
|
"cloud": "Cloud",
|
||||||
"cloudDesc": "Cloud services and storage",
|
"cloudDesc": "Cloud services and storage",
|
||||||
"network": "Network",
|
"network": "Network",
|
||||||
"networkDesc": "Network infrastructure and Web3 services",
|
"networkDesc": "Network infrastructure and mesh services",
|
||||||
"web5": "Web5",
|
"web5": "Web5",
|
||||||
"web5Desc": "Decentralized identity and data protocols",
|
"web5Desc": "Decentralized identity and data protocols",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
@ -147,6 +149,7 @@
|
|||||||
"updateAvailable": "Update Available: v{version}",
|
"updateAvailable": "Update Available: v{version}",
|
||||||
"updateNow": "Update Now",
|
"updateNow": "Update Now",
|
||||||
"goToApps": "Go to Apps",
|
"goToApps": "Go to Apps",
|
||||||
|
"goToAppStore": "Go to App Store",
|
||||||
"goToCloud": "Go to Cloud",
|
"goToCloud": "Go to Cloud",
|
||||||
"goToNetwork": "Go to Network",
|
"goToNetwork": "Go to Network",
|
||||||
"goToWeb5": "Go to Web5",
|
"goToWeb5": "Go to Web5",
|
||||||
@ -162,6 +165,8 @@
|
|||||||
"noResults": "No apps matching \"{query}\"",
|
"noResults": "No apps matching \"{query}\"",
|
||||||
"uninstallTitle": "Uninstall App?",
|
"uninstallTitle": "Uninstall App?",
|
||||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||||
|
"deleteAppDataLabel": "Delete app data and reset it",
|
||||||
|
"deleteAppDataHelp": "Check this to remove the app's data, backups, and persistent files. Leave it off to reinstall later without starting over.",
|
||||||
"dismissError": "Dismiss error",
|
"dismissError": "Dismiss error",
|
||||||
"searchLabel": "Search installed apps"
|
"searchLabel": "Search installed apps"
|
||||||
},
|
},
|
||||||
@ -172,7 +177,7 @@
|
|||||||
"interfaceMode": "Interface Mode",
|
"interfaceMode": "Interface Mode",
|
||||||
"claudeAuth": "Claude Authentication",
|
"claudeAuth": "Claude Authentication",
|
||||||
"aiDataAccess": "AI Data Access",
|
"aiDataAccess": "AI Data Access",
|
||||||
"serverName": "Server Name",
|
"serverName": "Hostname",
|
||||||
"sessionStatus": "Session Status",
|
"sessionStatus": "Session Status",
|
||||||
"yourDid": "Your DID",
|
"yourDid": "Your DID",
|
||||||
"onionAddress": "Node .onion Address",
|
"onionAddress": "Node .onion Address",
|
||||||
@ -301,7 +306,8 @@
|
|||||||
"webhookSendFailed": "Failed to send test webhook",
|
"webhookSendFailed": "Failed to send test webhook",
|
||||||
"passwordAllFieldsRequired": "All fields are required",
|
"passwordAllFieldsRequired": "All fields are required",
|
||||||
"passwordMismatch": "New passwords do not match",
|
"passwordMismatch": "New passwords do not match",
|
||||||
"passwordUpdatedSuccess": "Password updated successfully. Use the new password for login and SSH.",
|
"passwordUpdatedSuccess": "Web password updated successfully.",
|
||||||
|
"passwordUpdatedSshFailed": "SSH password update failed:",
|
||||||
"passwordChangeFailed": "Failed to change password",
|
"passwordChangeFailed": "Failed to change password",
|
||||||
"passwordMinLength": "Password must be at least 12 characters",
|
"passwordMinLength": "Password must be at least 12 characters",
|
||||||
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
||||||
@ -520,6 +526,10 @@
|
|||||||
"registerNewName": "Register New Name",
|
"registerNewName": "Register New Name",
|
||||||
"verifyNip05": "Verify NIP-05",
|
"verifyNip05": "Verify NIP-05",
|
||||||
"peers": "Peers",
|
"peers": "Peers",
|
||||||
|
"trusted": "Trusted",
|
||||||
|
"observer": "Observer",
|
||||||
|
"observers": "Observers",
|
||||||
|
"noObservers": "No observers yet.",
|
||||||
"messages": "Messages",
|
"messages": "Messages",
|
||||||
"requests": "Requests",
|
"requests": "Requests",
|
||||||
"myContent": "My Content",
|
"myContent": "My Content",
|
||||||
@ -538,6 +548,7 @@
|
|||||||
"features": "Features",
|
"features": "Features",
|
||||||
"information": "Information",
|
"information": "Information",
|
||||||
"requirements": "Requirements",
|
"requirements": "Requirements",
|
||||||
|
"setupInstructions": "Setup Instructions",
|
||||||
"ram": "RAM",
|
"ram": "RAM",
|
||||||
"ramDesc": "Minimum 512MB",
|
"ramDesc": "Minimum 512MB",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
@ -611,6 +622,7 @@
|
|||||||
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
|
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
|
||||||
"installApp": "Install {name}",
|
"installApp": "Install {name}",
|
||||||
"openAndConfigure": "Open & Configure",
|
"openAndConfigure": "Open & Configure",
|
||||||
|
"checkAndContinue": "Check & Continue",
|
||||||
"iveDoneThis": "I've Done This",
|
"iveDoneThis": "I've Done This",
|
||||||
"complete": "Complete",
|
"complete": "Complete",
|
||||||
"allSet": "All Set!",
|
"allSet": "All Set!",
|
||||||
|
|||||||
@ -112,10 +112,12 @@
|
|||||||
"setupTab": "Configuraci\u00f3n",
|
"setupTab": "Configuraci\u00f3n",
|
||||||
"myApps": "Mis aplicaciones",
|
"myApps": "Mis aplicaciones",
|
||||||
"myAppsDesc": "Administre sus aplicaciones instaladas",
|
"myAppsDesc": "Administre sus aplicaciones instaladas",
|
||||||
|
"recommendedApps": "Aplicaciones recomendadas",
|
||||||
|
"recommendedAppsDesc": "Agregue aplicaciones utiles de la tienda a su pantalla de inicio",
|
||||||
"cloud": "Nube",
|
"cloud": "Nube",
|
||||||
"cloudDesc": "Servicios en la nube y almacenamiento",
|
"cloudDesc": "Servicios en la nube y almacenamiento",
|
||||||
"network": "Red",
|
"network": "Red",
|
||||||
"networkDesc": "Infraestructura de red y servicios Web3",
|
"networkDesc": "Infraestructura de red y servicios mesh",
|
||||||
"web5": "Web5",
|
"web5": "Web5",
|
||||||
"web5Desc": "Identidad descentralizada y protocolos de datos",
|
"web5Desc": "Identidad descentralizada y protocolos de datos",
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
@ -147,6 +149,7 @@
|
|||||||
"updateAvailable": "Actualizaci\u00f3n disponible: v{version}",
|
"updateAvailable": "Actualizaci\u00f3n disponible: v{version}",
|
||||||
"updateNow": "Actualizar ahora",
|
"updateNow": "Actualizar ahora",
|
||||||
"goToApps": "Ir a aplicaciones",
|
"goToApps": "Ir a aplicaciones",
|
||||||
|
"goToAppStore": "Ir a la tienda de aplicaciones",
|
||||||
"goToCloud": "Ir a la nube",
|
"goToCloud": "Ir a la nube",
|
||||||
"goToNetwork": "Ir a la red",
|
"goToNetwork": "Ir a la red",
|
||||||
"goToWeb5": "Ir a Web5",
|
"goToWeb5": "Ir a Web5",
|
||||||
@ -162,6 +165,8 @@
|
|||||||
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
||||||
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
||||||
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
|
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
|
||||||
|
"deleteAppDataLabel": "Eliminar los datos de la aplicaci\u00f3n y restablecerla",
|
||||||
|
"deleteAppDataHelp": "Marque esto para eliminar los datos, respaldos y archivos persistentes de la aplicaci\u00f3n. Desm\u00e1rquelo para reinstalar m\u00e1s tarde sin empezar de cero.",
|
||||||
"dismissError": "Descartar error",
|
"dismissError": "Descartar error",
|
||||||
"searchLabel": "Buscar aplicaciones instaladas"
|
"searchLabel": "Buscar aplicaciones instaladas"
|
||||||
},
|
},
|
||||||
@ -172,7 +177,7 @@
|
|||||||
"interfaceMode": "Modo de interfaz",
|
"interfaceMode": "Modo de interfaz",
|
||||||
"claudeAuth": "Autenticaci\u00f3n de Claude",
|
"claudeAuth": "Autenticaci\u00f3n de Claude",
|
||||||
"aiDataAccess": "Acceso a datos de IA",
|
"aiDataAccess": "Acceso a datos de IA",
|
||||||
"serverName": "Nombre del servidor",
|
"serverName": "Hostname",
|
||||||
"sessionStatus": "Estado de la sesi\u00f3n",
|
"sessionStatus": "Estado de la sesi\u00f3n",
|
||||||
"yourDid": "Su DID",
|
"yourDid": "Su DID",
|
||||||
"onionAddress": "Direcci\u00f3n .onion del nodo",
|
"onionAddress": "Direcci\u00f3n .onion del nodo",
|
||||||
@ -301,7 +306,8 @@
|
|||||||
"webhookSendFailed": "Error al enviar el webhook de prueba",
|
"webhookSendFailed": "Error al enviar el webhook de prueba",
|
||||||
"passwordAllFieldsRequired": "Todos los campos son obligatorios",
|
"passwordAllFieldsRequired": "Todos los campos son obligatorios",
|
||||||
"passwordMismatch": "Las nuevas contrase\u00f1as no coinciden",
|
"passwordMismatch": "Las nuevas contrase\u00f1as no coinciden",
|
||||||
"passwordUpdatedSuccess": "Contrase\u00f1a actualizada exitosamente. Use la nueva contrase\u00f1a para iniciar sesi\u00f3n y SSH.",
|
"passwordUpdatedSuccess": "Contrase\u00f1a web actualizada exitosamente.",
|
||||||
|
"passwordUpdatedSshFailed": "Error al actualizar la contrase\u00f1a SSH:",
|
||||||
"passwordChangeFailed": "Error al cambiar la contrase\u00f1a",
|
"passwordChangeFailed": "Error al cambiar la contrase\u00f1a",
|
||||||
"passwordMinLength": "La contrase\u00f1a debe tener al menos 12 caracteres",
|
"passwordMinLength": "La contrase\u00f1a debe tener al menos 12 caracteres",
|
||||||
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
|
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
|
||||||
@ -520,6 +526,10 @@
|
|||||||
"registerNewName": "Registrar nuevo nombre",
|
"registerNewName": "Registrar nuevo nombre",
|
||||||
"verifyNip05": "Verificar NIP-05",
|
"verifyNip05": "Verificar NIP-05",
|
||||||
"peers": "Pares",
|
"peers": "Pares",
|
||||||
|
"trusted": "Confiables",
|
||||||
|
"observer": "Observador",
|
||||||
|
"observers": "Observadores",
|
||||||
|
"noObservers": "A\u00fan no hay observadores.",
|
||||||
"messages": "Mensajes",
|
"messages": "Mensajes",
|
||||||
"requests": "Solicitudes",
|
"requests": "Solicitudes",
|
||||||
"myContent": "Mi contenido",
|
"myContent": "Mi contenido",
|
||||||
@ -538,6 +548,7 @@
|
|||||||
"features": "Caracter\u00edsticas",
|
"features": "Caracter\u00edsticas",
|
||||||
"information": "Informaci\u00f3n",
|
"information": "Informaci\u00f3n",
|
||||||
"requirements": "Requisitos",
|
"requirements": "Requisitos",
|
||||||
|
"setupInstructions": "Instrucciones de configuraci\u00f3n",
|
||||||
"ram": "RAM",
|
"ram": "RAM",
|
||||||
"ramDesc": "M\u00ednimo 512MB",
|
"ramDesc": "M\u00ednimo 512MB",
|
||||||
"storage": "Almacenamiento",
|
"storage": "Almacenamiento",
|
||||||
@ -610,6 +621,7 @@
|
|||||||
"syncMessage": "Su nodo Bitcoin est\u00e1 sincronizando toda la cadena de bloques para que no tenga que confiar en nadie m\u00e1s. Esto toma 2\u20133 d\u00edas en la primera ejecuci\u00f3n. Mientras tanto, puede explorar su nodo, configurar su identidad o respaldar sus claves.",
|
"syncMessage": "Su nodo Bitcoin est\u00e1 sincronizando toda la cadena de bloques para que no tenga que confiar en nadie m\u00e1s. Esto toma 2\u20133 d\u00edas en la primera ejecuci\u00f3n. Mientras tanto, puede explorar su nodo, configurar su identidad o respaldar sus claves.",
|
||||||
"installApp": "Instalar {name}",
|
"installApp": "Instalar {name}",
|
||||||
"openAndConfigure": "Abrir y configurar",
|
"openAndConfigure": "Abrir y configurar",
|
||||||
|
"checkAndContinue": "Verificar y continuar",
|
||||||
"iveDoneThis": "Ya lo hice",
|
"iveDoneThis": "Ya lo hice",
|
||||||
"complete": "Completar",
|
"complete": "Completar",
|
||||||
"allSet": "\u00a1Todo listo!",
|
"allSet": "\u00a1Todo listo!",
|
||||||
|
|||||||
@ -12,7 +12,7 @@ vi.mock('vue-router', () => ({
|
|||||||
useRouter: () => ({ push: mockPush }),
|
useRouter: () => ({ push: mockPush }),
|
||||||
}))
|
}))
|
||||||
vi.mock('@/router', () => ({
|
vi.mock('@/router', () => ({
|
||||||
default: { push: mockPush },
|
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.stubGlobal('open', mockWindowOpen)
|
vi.stubGlobal('open', mockWindowOpen)
|
||||||
@ -66,7 +66,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
store.openSession('indeedhub')
|
store.openSession('indeedhub')
|
||||||
|
|
||||||
expect(store.panelAppId).toBe(null)
|
expect(store.panelAppId).toBe(null)
|
||||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' } })
|
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes localhost launch URLs to current host before resolving', () => {
|
it('normalizes localhost launch URLs to current host before resolving', () => {
|
||||||
@ -95,7 +95,12 @@ describe('useAppLauncherStore', () => {
|
|||||||
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
|
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
|
||||||
|
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
expect(store.panelAppId).toBe('btcpay-server')
|
expect(store.panelAppId).toBe(null)
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
'http://192.168.1.228:23000',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
|
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
|
||||||
@ -125,7 +130,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
expect(store.panelAppId).toBe(null)
|
expect(store.panelAppId).toBe(null)
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
|
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||||
@ -186,7 +191,12 @@ describe('useAppLauncherStore', () => {
|
|||||||
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
|
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
|
||||||
|
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
expect(store.panelAppId).toBeTruthy()
|
expect(store.panelAppId).toBe(null)
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
'http://192.168.1.228:8123',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes Grafana (port 3000) to full-page session', () => {
|
it('routes Grafana (port 3000) to full-page session', () => {
|
||||||
@ -195,7 +205,12 @@ describe('useAppLauncherStore', () => {
|
|||||||
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
|
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
|
||||||
|
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
expect(store.panelAppId).toBeTruthy()
|
expect(store.panelAppId).toBe(null)
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
'http://192.168.1.228:3000',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens Gitea path URL in new tab', () => {
|
it('opens Gitea path URL in new tab', () => {
|
||||||
@ -261,7 +276,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
|
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
|
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' }, query: { returnTo: '/dashboard/apps' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes HTTPS same-host apps via session view', () => {
|
it('routes HTTPS same-host apps via session view', () => {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import { recordAppLaunch } from '@/utils/appUsage'
|
||||||
|
|
||||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||||
const NEW_TAB_PORTS = new Set([
|
const NEW_TAB_PORTS = new Set([
|
||||||
@ -102,8 +103,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
|||||||
'8334': 'bitcoin-knots',
|
'8334': 'bitcoin-knots',
|
||||||
'8888': 'searxng',
|
'8888': 'searxng',
|
||||||
'9000': 'portainer',
|
'9000': 'portainer',
|
||||||
'9010': 'saleor',
|
|
||||||
'9011': 'saleor',
|
|
||||||
'8087': 'netbird',
|
'8087': 'netbird',
|
||||||
'8086': 'netbird',
|
'8086': 'netbird',
|
||||||
'9980': 'onlyoffice',
|
'9980': 'onlyoffice',
|
||||||
@ -186,21 +185,24 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
|
|
||||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||||
function dashboardReturnPath(): string {
|
function dashboardReturnPath(): string {
|
||||||
const current = router.currentRoute.value
|
const current = router.currentRoute?.value
|
||||||
|
if (!current) return '/dashboard/apps'
|
||||||
const fullPath = current.fullPath || '/dashboard/apps'
|
const fullPath = current.fullPath || '/dashboard/apps'
|
||||||
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
|
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
|
||||||
return fullPath
|
return fullPath
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSession(appId: string) {
|
function openSession(appId: string) {
|
||||||
|
recordAppLaunch(appId)
|
||||||
|
const mobile = isMobileViewport()
|
||||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||||
if (launchUrl) {
|
if (launchUrl && !mobile) {
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||||
if (mode === 'panel' && !isMobileViewport()) {
|
if (mode === 'panel' && !mobile) {
|
||||||
panelAppId.value = appId
|
panelAppId.value = appId
|
||||||
} else {
|
} else {
|
||||||
panelAppId.value = null
|
panelAppId.value = null
|
||||||
@ -219,6 +221,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||||
|
|
||||||
if (!isMobileViewport() && payload.openInNewTab) {
|
if (!isMobileViewport() && payload.openInNewTab) {
|
||||||
|
if (resolvedId) recordAppLaunch(resolvedId)
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -227,6 +230,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// phones, route through the app session/webview so app icons behave like
|
// phones, route through the app session/webview so app icons behave like
|
||||||
// native launchers and keep the user inside Archipelago.
|
// native launchers and keep the user inside Archipelago.
|
||||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||||
|
recordAppLaunch(resolvedId)
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; stat
|
|||||||
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
||||||
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
||||||
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
||||||
'waiting-healthy': { progress: 88, message: 'Waiting for container…', status: 'starting' },
|
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
|
||||||
'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' },
|
'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' },
|
||||||
'done': { progress: 100, message: 'Installed', status: 'complete' },
|
'done': { progress: 100, message: 'Installed', status: 'complete' },
|
||||||
}
|
}
|
||||||
@ -126,6 +126,12 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
installingApps.value.delete(appId)
|
installingApps.value.delete(appId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((pkg.state as string) === 'removing') {
|
||||||
|
uninstallingApps.value.add(appId)
|
||||||
|
} else if (uninstallingApps.value.has(appId)) {
|
||||||
|
uninstallingApps.value.delete(appId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Clear installingApps entries for apps that vanished from backend data
|
// Clear installingApps entries for apps that vanished from backend data
|
||||||
// Only clean up entries that have errored — active installs may take minutes to pull images
|
// Only clean up entries that have errored — active installs may take minutes to pull images
|
||||||
@ -183,8 +189,11 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
return rpcClient.installPackage(id, marketplaceUrl, version)
|
return rpcClient.installPackage(id, marketplaceUrl, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
|
async function uninstallPackage(
|
||||||
return rpcClient.uninstallPackage(id)
|
id: string,
|
||||||
|
options: { preserveData?: boolean } = {},
|
||||||
|
): Promise<{ status: string; package_id: string }> {
|
||||||
|
return rpcClient.uninstallPackage(id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startPackage(id: string): Promise<void> {
|
async function startPackage(id: string): Promise<void> {
|
||||||
|
|||||||
@ -80,6 +80,50 @@ select:focus-visible {
|
|||||||
border-color: rgba(251, 146, 60, 0.4);
|
border-color: rgba(251, 146, 60, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Card action placement: keep compact header buttons for genuinely wide layouts. */
|
||||||
|
.responsive-card-actions-top,
|
||||||
|
.web5-card-actions-top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-card-actions-bottom,
|
||||||
|
.web5-card-actions-bottom {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-card-actions-bottom-grid,
|
||||||
|
.web5-card-actions-bottom-grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card-action {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
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 */
|
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
||||||
@media (max-width: 767px) {
|
@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"]) {
|
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"]) {
|
||||||
@ -140,7 +184,7 @@ input[type="radio"]:active + * {
|
|||||||
[data-controller-container]:focus-visible,
|
[data-controller-container]:focus-visible,
|
||||||
[data-controller-container]:focus {
|
[data-controller-container]:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
transform: translateY(-4px) scale(1.01) translateZ(0);
|
transform: translateY(-4px) scale(1.01);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 6px 2px rgba(251, 146, 60, 0.35),
|
0 0 6px 2px rgba(251, 146, 60, 0.35),
|
||||||
0 0 20px rgba(251, 146, 60, 0.15),
|
0 0 20px rgba(251, 146, 60, 0.15),
|
||||||
@ -178,16 +222,18 @@ input[type="radio"]:active + * {
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
/* Fix Chromium compositor bug: backdrop-filter + fixed animated overlays
|
|
||||||
causes cards to render as black rectangles on scroll/tab-switch.
|
|
||||||
Own layer + isolation prevents stacking context confusion. */
|
|
||||||
transform: translateZ(0);
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Apps grid has many backdrop-filter cards inside an animated dashboard
|
/* Dashboard content lives inside animated perspective/scroll containers.
|
||||||
viewport. Chromium/Brave can corrupt those layers into black rectangles,
|
Chromium/Brave can corrupt backdrop-filter + transformed cards into black
|
||||||
so keep the translucency but avoid per-card backdrop compositor layers. */
|
square/rectangle layers, so use translucent fills there instead. */
|
||||||
|
body.dashboard-active .dashboard-scroll-panel .glass-card,
|
||||||
|
body.dashboard-active .dashboard-scroll-panel .glass,
|
||||||
|
body.dashboard-active .dashboard-scroll-panel .mode-switcher,
|
||||||
|
body.dashboard-active .dashboard-scroll-panel .glass-button,
|
||||||
|
body.dashboard-active .dashboard-scroll-panel input,
|
||||||
|
body.dashboard-active .dashboard-scroll-panel textarea,
|
||||||
|
body.dashboard-active .dashboard-scroll-panel select,
|
||||||
.apps-view .glass-card,
|
.apps-view .glass-card,
|
||||||
.apps-view .glass,
|
.apps-view .glass,
|
||||||
.apps-view .mode-switcher,
|
.apps-view .mode-switcher,
|
||||||
@ -209,6 +255,84 @@ input[type="radio"]:active + * {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segmented-select {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
max-width: min(260px, 24vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-select-with-discover {
|
||||||
|
max-width: min(360px, 34vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tabs-wide {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tabs-wide .mode-switcher-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tabs-probe {
|
||||||
|
position: absolute;
|
||||||
|
left: -10000px;
|
||||||
|
top: -10000px;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
width: max-content;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-search,
|
||||||
|
.app-header-search-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-select .mode-switcher-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-select-control {
|
||||||
|
width: clamp(132px, 18vw, 220px);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.68) 50%) right 12px center / 6px 6px no-repeat,
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.68) 50%, transparent 50%) right 8px center / 6px 6px no-repeat,
|
||||||
|
transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-right: 1.75rem;
|
||||||
|
padding: 0.375rem 1.75rem 0.375rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-select-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(251, 146, 60, 0.35);
|
||||||
|
background-color: rgba(251, 146, 60, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-select-control option {
|
||||||
|
background: #111827;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Full-width mode switcher variant (sidebar, mobile settings) */
|
/* Full-width mode switcher variant (sidebar, mobile settings) */
|
||||||
.mode-switcher-full {
|
.mode-switcher-full {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1305,6 +1429,32 @@ html:has(body.video-background-active)::before {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-screen modal overlays should freeze the page underneath, including
|
||||||
|
custom Teleport modals that do not go through BaseModal. */
|
||||||
|
html:has(body .fixed.inset-0:not(.pointer-events-none)),
|
||||||
|
body:has(.fixed.inset-0:not(.pointer-events-none)) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.modal-scroll-locked .fixed.inset-0 {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.modal-scroll-locked .fixed.inset-0 .overflow-y-auto,
|
||||||
|
html.modal-scroll-locked .fixed.inset-0 .overflow-auto,
|
||||||
|
html.modal-scroll-locked .fixed.inset-0 [style*="overflow-y: auto"],
|
||||||
|
html.modal-scroll-locked .fixed.inset-0 [style*="overflow: auto"] {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.modal-scroll-locked .dashboard-scroll-panel {
|
||||||
|
overflow: hidden !important;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Repeating glitch animations - every 5 seconds (subtle) */
|
/* Repeating glitch animations - every 5 seconds (subtle) */
|
||||||
@keyframes bg-glitch-shift-repeat {
|
@keyframes bg-glitch-shift-repeat {
|
||||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||||
@ -1677,6 +1827,16 @@ html:has(body.video-background-active)::before {
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.marketplace-container {
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.marketplace-container {
|
||||||
|
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-category-strip::-webkit-scrollbar {
|
.mobile-category-strip::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -2085,11 +2245,9 @@ html:has(body.video-background-active)::before {
|
|||||||
@keyframes card-stagger-in {
|
@keyframes card-stagger-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2139,14 +2297,27 @@ html:has(body.video-background-active)::before {
|
|||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(0, 0, 0, 0.72);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archy-app-icon {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
object-fit: cover;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 35% 28%, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0) 42%),
|
||||||
|
linear-gradient(145deg, rgba(22, 22, 24, 0.96), rgba(0, 0, 0, 0.96));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||||
|
inset 0 -10px 24px rgba(0, 0, 0, 0.34),
|
||||||
|
0 8px 18px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-icon-img {
|
.app-icon-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2202,6 +2373,19 @@ html:has(body.video-background-active)::before {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-icon-progress-label {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-width: 84px;
|
||||||
|
min-height: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Page indicator dots */
|
/* Page indicator dots */
|
||||||
.app-icon-dots {
|
.app-icon-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -2237,10 +2421,15 @@ html:has(body.video-background-active)::before {
|
|||||||
|
|
||||||
/* Monitoring dashboard */
|
/* Monitoring dashboard */
|
||||||
.monitoring-stat-card {
|
.monitoring-stat-card {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.58);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitoring-stat-card-compact {
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monitoring-chart {
|
.monitoring-chart {
|
||||||
@ -2370,10 +2559,12 @@ html:has(body.video-background-active)::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fleet-sort-btn {
|
.fleet-sort-btn {
|
||||||
padding: 4px 10px;
|
padding: 5px 11px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.625rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
@ -87,6 +87,7 @@ export interface PackageDataEntry {
|
|||||||
license: string
|
license: string
|
||||||
instructions: string
|
instructions: string
|
||||||
icon: string
|
icon: string
|
||||||
|
screenshots?: AppScreenshot[]
|
||||||
}
|
}
|
||||||
manifest: Manifest
|
manifest: Manifest
|
||||||
installed?: InstalledPackageDataEntry
|
installed?: InstalledPackageDataEntry
|
||||||
@ -121,6 +122,12 @@ export interface Manifest {
|
|||||||
'lan-config'?: string
|
'lan-config'?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
screenshots?: AppScreenshot[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppScreenshot = string | {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstalledPackageDataEntry {
|
export interface InstalledPackageDataEntry {
|
||||||
|
|||||||
31
neode-ui/src/utils/__tests__/appUsage.test.ts
Normal file
31
neode-ui/src/utils/__tests__/appUsage.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getAppUsage, recordAppLaunch } from '../appUsage'
|
||||||
|
|
||||||
|
describe('appUsage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts empty when no usage has been recorded', () => {
|
||||||
|
expect(getAppUsage()).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records launch count and latest launch time', () => {
|
||||||
|
recordAppLaunch('filebrowser', 1000)
|
||||||
|
recordAppLaunch('filebrowser', 2000)
|
||||||
|
|
||||||
|
expect(getAppUsage()).toEqual({
|
||||||
|
filebrowser: {
|
||||||
|
count: 2,
|
||||||
|
lastLaunchedAt: 2000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores corrupt stored usage', () => {
|
||||||
|
localStorage.setItem('archipelago-app-usage', 'not-json')
|
||||||
|
|
||||||
|
expect(getAppUsage()).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
20
neode-ui/src/utils/__tests__/bitcoinReceive.test.ts
Normal file
20
neode-ui/src/utils/__tests__/bitcoinReceive.test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { explainReceiveAddressFailure } from '../bitcoinReceive'
|
||||||
|
|
||||||
|
describe('explainReceiveAddressFailure', () => {
|
||||||
|
it('explains locked wallet failures', () => {
|
||||||
|
expect(explainReceiveAddressFailure(new Error('wallet locked'))).toContain('wallet is locked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains sync failures', () => {
|
||||||
|
expect(explainReceiveAddressFailure(new Error('chain backend is still syncing'))).toContain('still syncing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains empty address responses', () => {
|
||||||
|
expect(explainReceiveAddressFailure(new Error('LND did not return a Bitcoin address'))).toContain('did not return an address')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains lnd transport failures', () => {
|
||||||
|
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
|
||||||
|
})
|
||||||
|
})
|
||||||
31
neode-ui/src/utils/__tests__/introSplash.test.ts
Normal file
31
neode-ui/src/utils/__tests__/introSplash.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { shouldShowIntroSplash } from '../introSplash'
|
||||||
|
|
||||||
|
describe('shouldShowIntroSplash', () => {
|
||||||
|
it('skips intro on an already-onboarded node even without a browser intro flag', () => {
|
||||||
|
expect(shouldShowIntroSplash({
|
||||||
|
seenIntro: false,
|
||||||
|
routePath: '/',
|
||||||
|
fromBoot: false,
|
||||||
|
onboardingComplete: true,
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows intro for a fresh root visit when onboarding is not complete', () => {
|
||||||
|
expect(shouldShowIntroSplash({
|
||||||
|
seenIntro: false,
|
||||||
|
routePath: '/',
|
||||||
|
fromBoot: false,
|
||||||
|
onboardingComplete: false,
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not interrupt direct routes', () => {
|
||||||
|
expect(shouldShowIntroSplash({
|
||||||
|
seenIntro: false,
|
||||||
|
routePath: '/dashboard/web5',
|
||||||
|
fromBoot: false,
|
||||||
|
onboardingComplete: null,
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
43
neode-ui/src/utils/appUsage.ts
Normal file
43
neode-ui/src/utils/appUsage.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
const STORAGE_KEY = 'archipelago-app-usage'
|
||||||
|
|
||||||
|
export interface AppUsageEntry {
|
||||||
|
count: number
|
||||||
|
lastLaunchedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppUsageMap = Record<string, AppUsageEntry>
|
||||||
|
|
||||||
|
function readUsage(): AppUsageMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw) as AppUsageMap
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {}
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUsage(usage: AppUsageMap) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(usage))
|
||||||
|
} catch {
|
||||||
|
// Ignore unavailable or full localStorage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordAppLaunch(appId: string, now = Date.now()) {
|
||||||
|
if (!appId) return
|
||||||
|
const usage = readUsage()
|
||||||
|
const current = usage[appId]
|
||||||
|
usage[appId] = {
|
||||||
|
count: (current?.count || 0) + 1,
|
||||||
|
lastLaunchedAt: now,
|
||||||
|
}
|
||||||
|
writeUsage(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppUsage(): AppUsageMap {
|
||||||
|
return readUsage()
|
||||||
|
}
|
||||||
25
neode-ui/src/utils/bitcoinReceive.ts
Normal file
25
neode-ui/src/utils/bitcoinReceive.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export function explainReceiveAddressFailure(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '')
|
||||||
|
const lower = message.toLowerCase()
|
||||||
|
|
||||||
|
if (lower.includes('wallet') && (lower.includes('locked') || lower.includes('unlock'))) {
|
||||||
|
return 'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.'
|
||||||
|
}
|
||||||
|
if (lower.includes('uninitialized') || lower.includes('not initialized') || lower.includes('initwallet')) {
|
||||||
|
return 'Bitcoin address is not ready because the Lightning wallet has not been initialized yet.'
|
||||||
|
}
|
||||||
|
if (lower.includes('sync') || lower.includes('chain backend') || lower.includes('neutrino')) {
|
||||||
|
return 'Bitcoin address is not ready while Bitcoin or LND is still syncing. Try again once sync has progressed.'
|
||||||
|
}
|
||||||
|
if (lower.includes('rest connection failed') || lower.includes('failed to parse newaddress response')) {
|
||||||
|
return 'Bitcoin address is not ready because LND is not responding cleanly yet. Check that the Lightning app is healthy and retry.'
|
||||||
|
}
|
||||||
|
if (lower.includes('connection') || lower.includes('connect') || lower.includes('unavailable') || lower.includes('refused')) {
|
||||||
|
return 'Bitcoin address is not ready because LND is not reachable yet. Check that the Lightning app is running.'
|
||||||
|
}
|
||||||
|
if (lower.includes('did not return') || lower.includes('empty address')) {
|
||||||
|
return 'Bitcoin address is not ready because LND did not return an address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || 'Bitcoin address is not ready yet. Check Bitcoin and LND status, then try again.'
|
||||||
|
}
|
||||||
17
neode-ui/src/utils/introSplash.ts
Normal file
17
neode-ui/src/utils/introSplash.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface IntroSplashDecisionInput {
|
||||||
|
seenIntro: boolean
|
||||||
|
routePath: string
|
||||||
|
fromBoot: boolean
|
||||||
|
devMode?: string
|
||||||
|
onboardingComplete: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
||||||
|
if (input.seenIntro) return false
|
||||||
|
if (input.onboardingComplete === true) return false
|
||||||
|
|
||||||
|
const isDirectRoute = input.routePath !== '/'
|
||||||
|
if (input.fromBoot) return true
|
||||||
|
if (input.devMode === 'boot') return false
|
||||||
|
return !isDirectRoute
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@
|
|||||||
:package-key="packageKey"
|
:package-key="packageKey"
|
||||||
:can-launch="canLaunch"
|
:can-launch="canLaunch"
|
||||||
:is-web-only="isWebOnly"
|
:is-web-only="isWebOnly"
|
||||||
|
:pending-action="pendingAction"
|
||||||
@launch="launchApp"
|
@launch="launchApp"
|
||||||
@start="startApp"
|
@start="startApp"
|
||||||
@stop="stopApp"
|
@stop="stopApp"
|
||||||
@ -57,6 +58,7 @@
|
|||||||
:tor-url="torUrl"
|
:tor-url="torUrl"
|
||||||
:show-tor-address="showTorAddress"
|
:show-tor-address="showTorAddress"
|
||||||
:credentials="credentials"
|
:credentials="credentials"
|
||||||
|
:credentials-loading="credentialsLoading"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -70,52 +72,13 @@
|
|||||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uninstall Confirmation Modal -->
|
<AppsUninstallModal
|
||||||
<Teleport to="body">
|
:show="uninstallModal.show"
|
||||||
<Transition name="modal">
|
:app-title="uninstallModal.appTitle"
|
||||||
<div
|
:uninstalling="pendingAction === 'uninstall'"
|
||||||
v-if="uninstallModal.show"
|
@close="closeUninstallModal"
|
||||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
@confirm="confirmUninstall"
|
||||||
@click="closeUninstallModal()"
|
/>
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
|
||||||
<div
|
|
||||||
ref="uninstallModalRef"
|
|
||||||
@click.stop
|
|
||||||
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-4 mb-6">
|
|
||||||
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
|
||||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
|
||||||
<p class="text-white/70 text-sm">
|
|
||||||
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
|
||||||
<button
|
|
||||||
@click="closeUninstallModal()"
|
|
||||||
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{ t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="confirmUninstall"
|
|
||||||
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{ t('common.uninstall') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Action error toast -->
|
<!-- Action error toast -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
@ -135,14 +98,15 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
||||||
import { dummyApps } from '../utils/dummyApps'
|
import { dummyApps } from '../utils/dummyApps'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import type { AppCredentialsResponse } from '@/types/api'
|
import type { AppCredentialsResponse } from '@/types/api'
|
||||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||||
|
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||||
|
import { resolveAppCredentials } from './apps/appCredentials'
|
||||||
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
||||||
import {
|
import {
|
||||||
WEB_ONLY_APP_URLS,
|
WEB_ONLY_APP_URLS,
|
||||||
@ -217,6 +181,8 @@ const bitcoinSyncPercent = ref(0)
|
|||||||
const bitcoinBlockHeight = ref(0)
|
const bitcoinBlockHeight = ref(0)
|
||||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||||
const credentials = ref<AppCredentialsResponse | null>(null)
|
const credentials = ref<AppCredentialsResponse | null>(null)
|
||||||
|
const credentialsLoading = ref(false)
|
||||||
|
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||||
|
|
||||||
async function loadBitcoinSync() {
|
async function loadBitcoinSync() {
|
||||||
if (!needsBitcoinSync.value) return
|
if (!needsBitcoinSync.value) return
|
||||||
@ -235,15 +201,18 @@ async function loadBitcoinSync() {
|
|||||||
|
|
||||||
async function loadCredentials() {
|
async function loadCredentials() {
|
||||||
if (!appId.value) return
|
if (!appId.value) return
|
||||||
|
credentialsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||||
method: 'package.credentials',
|
method: 'package.credentials',
|
||||||
params: { app_id: packageKey.value },
|
params: { app_id: packageKey.value },
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
})
|
})
|
||||||
credentials.value = result.credentials?.length ? result : null
|
credentials.value = resolveAppCredentials(packageKey.value, result)
|
||||||
} catch {
|
} catch {
|
||||||
credentials.value = null
|
credentials.value = resolveAppCredentials(packageKey.value, null)
|
||||||
|
} finally {
|
||||||
|
credentialsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,21 +231,11 @@ function showActionError(msg: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uninstallModal = ref({ show: false, appTitle: '' })
|
const uninstallModal = ref({ show: false, appTitle: '' })
|
||||||
const uninstallModalRef = ref<HTMLElement | null>(null)
|
|
||||||
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
function closeUninstallModal() {
|
function closeUninstallModal() {
|
||||||
uninstallRestoreFocusRef.value?.focus?.()
|
|
||||||
uninstallModal.value.show = false
|
uninstallModal.value.show = false
|
||||||
}
|
}
|
||||||
|
|
||||||
useModalKeyboard(
|
|
||||||
uninstallModalRef,
|
|
||||||
computed(() => uninstallModal.value.show),
|
|
||||||
closeUninstallModal,
|
|
||||||
{ restoreFocusRef: uninstallRestoreFocusRef }
|
|
||||||
)
|
|
||||||
|
|
||||||
const backButtonText = computed(() => {
|
const backButtonText = computed(() => {
|
||||||
if (route.query.from === 'discover') return 'Back to Discover'
|
if (route.query.from === 'discover') return 'Back to Discover'
|
||||||
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
||||||
@ -300,7 +259,15 @@ const features = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.back()
|
if (route.query.from === 'discover') {
|
||||||
|
router.push('/dashboard/discover').catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.query.from === 'marketplace') {
|
||||||
|
router.push('/dashboard/marketplace').catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/dashboard/apps').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchApp() {
|
function launchApp() {
|
||||||
@ -335,34 +302,46 @@ function launchApp() {
|
|||||||
|
|
||||||
|
|
||||||
async function startApp() {
|
async function startApp() {
|
||||||
|
pendingAction.value = 'start'
|
||||||
try {
|
try {
|
||||||
await store.startPackage(appId.value)
|
await store.startPackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
pendingAction.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopApp() {
|
async function stopApp() {
|
||||||
|
pendingAction.value = 'stop'
|
||||||
try {
|
try {
|
||||||
await store.stopPackage(appId.value)
|
await store.stopPackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
pendingAction.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartApp() {
|
async function restartApp() {
|
||||||
|
pendingAction.value = 'restart'
|
||||||
try {
|
try {
|
||||||
await store.restartPackage(appId.value)
|
await store.restartPackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
pendingAction.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateApp() {
|
async function updateApp() {
|
||||||
|
pendingAction.value = 'update'
|
||||||
try {
|
try {
|
||||||
await store.updatePackage(appId.value)
|
await store.updatePackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
pendingAction.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,13 +350,16 @@ function showUninstallModal() {
|
|||||||
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
|
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmUninstall() {
|
async function confirmUninstall(deleteAppData: boolean) {
|
||||||
uninstallModal.value.show = false
|
uninstallModal.value.show = false
|
||||||
|
pendingAction.value = 'uninstall'
|
||||||
try {
|
try {
|
||||||
await store.uninstallPackage(appId.value)
|
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
|
||||||
router.push('/dashboard/apps').catch(() => {})
|
router.push('/dashboard/apps').catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
pendingAction.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="addingRegistry"
|
v-if="addingRegistry"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
|
||||||
@click.self="cancelAddRegistry"
|
@click.self="cancelAddRegistry"
|
||||||
>
|
>
|
||||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||||
|
|||||||
@ -32,6 +32,8 @@
|
|||||||
:must-open-new-tab="mustOpenNewTab"
|
:must-open-new-tab="mustOpenNewTab"
|
||||||
:auto-retry-count="autoRetryCount"
|
:auto-retry-count="autoRetryCount"
|
||||||
:refresh-key="refreshKey"
|
:refresh-key="refreshKey"
|
||||||
|
:blocked-reason="blockedReason"
|
||||||
|
:blocked-title="blockedTitle"
|
||||||
@iframe-load="onLoad"
|
@iframe-load="onLoad"
|
||||||
@iframe-error="onError"
|
@iframe-error="onError"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
@ -101,6 +103,7 @@ import {
|
|||||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||||
resolveAppUrl, resolveAppTitle,
|
resolveAppUrl, resolveAppTitle,
|
||||||
} from './appSession/appSessionConfig'
|
} from './appSession/appSessionConfig'
|
||||||
|
import { launchBlockedReason } from './apps/appsConfig'
|
||||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||||
|
|
||||||
@ -147,6 +150,9 @@ const appId = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||||
|
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||||
|
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||||
|
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||||
@ -344,8 +350,9 @@ watch(displayMode, (mode) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Apps that block iframes open externally instead of landing in a broken webview.
|
// Apps that block iframes open externally on desktop. On mobile, keep the
|
||||||
if (mustOpenNewTab.value && appUrl.value) {
|
// session surface visible so launcher taps do not bounce straight out.
|
||||||
|
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
|
||||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||||
if (isInlinePanel.value) emit('close')
|
if (isInlinePanel.value) emit('close')
|
||||||
else closeRouteSession()
|
else closeRouteSession()
|
||||||
@ -355,7 +362,7 @@ onMounted(() => {
|
|||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
window.addEventListener('message', onMessage)
|
window.addEventListener('message', onMessage)
|
||||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
iframeBlocked.value = true
|
iframeBlocked.value = true
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -4,12 +4,12 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<!-- Desktop: page tabs + category tabs + search -->
|
<!-- Desktop: page tabs + category tabs + search -->
|
||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
<div class="mode-switcher flex-shrink-0">
|
<div class="mode-switcher hidden md:inline-flex flex-shrink-0">
|
||||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
|
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
|
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
v-for="category in categoriesWithApps"
|
v-for="category in categoriesWithApps"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
@ -18,7 +18,7 @@
|
|||||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||||
>{{ category.name }}</button>
|
>{{ category.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex items-center gap-2">
|
<div class="app-header-search-wrap flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@ -88,6 +88,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Container scanner still warming up -->
|
||||||
|
<div v-else-if="isCheckingContainers" class="text-center py-16 pb-6">
|
||||||
|
<div class="glass-card p-8 max-w-md mx-auto">
|
||||||
|
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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>
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-2">Checking containers</h3>
|
||||||
|
<p class="text-white/70">Archipelago is scanning installed apps. Your apps will appear here as soon as the container list is ready.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
||||||
<div class="glass-card p-12 max-w-md mx-auto">
|
<div class="glass-card p-12 max-w-md mx-auto">
|
||||||
@ -134,6 +146,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isUsingLastKnownPackages && filteredPackageEntries.length > 0"
|
||||||
|
class="mb-4 rounded-lg border border-yellow-400/20 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100/85 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg class="animate-spin h-4 w-4 flex-shrink-0 text-yellow-200/80" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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>
|
||||||
|
<span>Refreshing container state. Showing the last known app list until the scan finishes.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: iPhone-style icon grid -->
|
<!-- Mobile: iPhone-style icon grid -->
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<AppIconGrid
|
<AppIconGrid
|
||||||
@ -176,7 +199,7 @@
|
|||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="credentialModal.show"
|
v-if="credentialModal.show"
|
||||||
class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
|
||||||
@click.self="closeCredentialModal"
|
@click.self="closeCredentialModal"
|
||||||
>
|
>
|
||||||
<div class="sideload-modal credential-modal">
|
<div class="sideload-modal credential-modal">
|
||||||
@ -290,7 +313,10 @@ import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry,
|
|||||||
import AppCard from './apps/AppCard.vue'
|
import AppCard from './apps/AppCard.vue'
|
||||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||||
|
import { resolveAppCredentials } from './apps/appCredentials'
|
||||||
|
import { useLastKnownPackages } from './apps/appPackageCache'
|
||||||
import { useAppsActions } from './apps/useAppsActions'
|
import { useAppsActions } from './apps/useAppsActions'
|
||||||
|
import { validateSideloadRequest } from './apps/sideloadValidation'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import {
|
import {
|
||||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||||
@ -351,9 +377,16 @@ const selectedCategory = ref('all')
|
|||||||
|
|
||||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
||||||
|
|
||||||
|
const livePackages = computed(() => store.packages || {})
|
||||||
|
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
|
||||||
|
const {
|
||||||
|
packages: stablePackages,
|
||||||
|
isUsingLastKnownPackages,
|
||||||
|
} = useLastKnownPackages(livePackages, containersScanned)
|
||||||
|
|
||||||
// Merge real packages from store with web-only app bookmarks + installing placeholders
|
// Merge real packages from store with web-only app bookmarks + installing placeholders
|
||||||
const packages = computed(() => {
|
const packages = computed(() => {
|
||||||
const realPackages = store.packages || {}
|
const realPackages = stablePackages.value
|
||||||
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
|
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
|
||||||
|
|
||||||
// Inject placeholder entries for apps being installed that aren't in backend data yet
|
// Inject placeholder entries for apps being installed that aren't in backend data yet
|
||||||
@ -393,6 +426,14 @@ const marketplaceMatches = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||||
|
const isCheckingContainers = computed(() => (
|
||||||
|
store.hasLoadedInitialData &&
|
||||||
|
Object.keys(livePackages.value).length === 0 &&
|
||||||
|
!isUsingLastKnownPackages.value &&
|
||||||
|
sortedPackageEntries.value.length === 0 &&
|
||||||
|
!searchQuery.value &&
|
||||||
|
!containersScanned.value
|
||||||
|
))
|
||||||
|
|
||||||
// Connection error state
|
// Connection error state
|
||||||
const connectionError = ref('')
|
const connectionError = ref('')
|
||||||
@ -457,13 +498,13 @@ function closeUninstallModal() {
|
|||||||
uninstallModal.value.show = false
|
uninstallModal.value.show = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onConfirmUninstall() {
|
async function onConfirmUninstall(deleteAppData: boolean) {
|
||||||
const { appId } = uninstallModal.value
|
const { appId } = uninstallModal.value
|
||||||
// Close the modal immediately so the user can fire off concurrent
|
// Close the modal immediately so the user can fire off concurrent
|
||||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||||
// its uninstall is in flight.
|
// its uninstall is in flight.
|
||||||
uninstallModal.value.show = false
|
uninstallModal.value.show = false
|
||||||
await actions.confirmUninstall(appId)
|
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToApp(id: string) {
|
function goToApp(id: string) {
|
||||||
@ -508,18 +549,29 @@ async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
|||||||
params: { app_id: id },
|
params: { app_id: id },
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
})
|
})
|
||||||
if (!result.credentials?.length) return false
|
const credentials = resolveAppCredentials(id, result)
|
||||||
|
if (!credentials) return false
|
||||||
credentialModal.value = {
|
credentialModal.value = {
|
||||||
show: true,
|
show: true,
|
||||||
appId: id,
|
appId: id,
|
||||||
title: result.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||||
credentials: result.credentials,
|
credentials: credentials.credentials,
|
||||||
copied: '',
|
copied: '',
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
const credentials = resolveAppCredentials(id, null)
|
||||||
|
if (!credentials) return false
|
||||||
|
credentialModal.value = {
|
||||||
|
show: true,
|
||||||
|
appId: id,
|
||||||
|
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||||
|
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||||
|
credentials: credentials.credentials,
|
||||||
|
copied: '',
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -587,6 +639,11 @@ async function submitSideload() {
|
|||||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const validationError = validateSideloadRequest(id, port, store.packages)
|
||||||
|
if (validationError) {
|
||||||
|
sideloadError.value = validationError
|
||||||
|
return
|
||||||
|
}
|
||||||
sideloading.value = true
|
sideloading.value = true
|
||||||
sideloadError.value = ''
|
sideloadError.value = ''
|
||||||
const containerConfig: Record<string, unknown> = {}
|
const containerConfig: Record<string, unknown> = {}
|
||||||
@ -693,6 +750,10 @@ async function submitSideload() {
|
|||||||
.credential-modal {
|
.credential-modal {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
.credential-modal-body {
|
.credential-modal-body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@ -83,6 +83,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="peersLoading && peerNodes.length > 0"
|
||||||
|
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5" 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>
|
||||||
|
Refreshing peer nodes...
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 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"
|
||||||
@ -263,12 +274,13 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadPeers() {
|
async function loadPeers() {
|
||||||
|
const hadPeers = peerNodes.value.length > 0
|
||||||
peersLoading.value = true
|
peersLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.federationListNodes()
|
const result = await rpcClient.federationListNodes()
|
||||||
peerNodes.value = result?.nodes ?? []
|
peerNodes.value = result?.nodes ?? []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
peerNodes.value = []
|
if (!hadPeers) peerNodes.value = []
|
||||||
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||||
} finally {
|
} finally {
|
||||||
peersLoading.value = false
|
peersLoading.value = false
|
||||||
@ -283,4 +295,6 @@ function peerDisplayName(did: string): string {
|
|||||||
function openSection(section: ContentSection) {
|
function openSection(section: ContentSection) {
|
||||||
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ loadPeers })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
Back to Cloud
|
{{ backLabel }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
|
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
|
||||||
@ -18,7 +18,7 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Back to Cloud</span>
|
<span>{{ backLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
@ -106,7 +106,7 @@
|
|||||||
<CloudToolbar
|
<CloudToolbar
|
||||||
:breadcrumbs="cloudStore.breadcrumbs"
|
:breadcrumbs="cloudStore.breadcrumbs"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
@navigate="cloudStore.navigate($event)"
|
@navigate="navigateCloudPath"
|
||||||
@refresh="cloudStore.refresh()"
|
@refresh="cloudStore.refresh()"
|
||||||
@upload="handleUpload"
|
@upload="handleUpload"
|
||||||
@update:view-mode="viewMode = $event"
|
@update:view-mode="viewMode = $event"
|
||||||
@ -115,7 +115,7 @@
|
|||||||
:items="cloudStore.sortedItems"
|
:items="cloudStore.sortedItems"
|
||||||
:loading="cloudStore.loading"
|
:loading="cloudStore.loading"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
@navigate="cloudStore.navigate($event)"
|
@navigate="navigateCloudPath"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@share="handleShare"
|
@share="handleShare"
|
||||||
@ -178,6 +178,7 @@ import ShareModal from '../components/cloud/ShareModal.vue'
|
|||||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||||
import { getFileCategory } from '../composables/useFileType'
|
import { getFileCategory } from '../composables/useFileType'
|
||||||
|
import { normalizeCloudPath, parentCloudPath } from './cloudPath'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -189,6 +190,7 @@ const audioPlayer = useAudioPlayer()
|
|||||||
const iframeLoaded = ref(false)
|
const iframeLoaded = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const folderId = computed(() => route.params.folderId as string)
|
const folderId = computed(() => route.params.folderId as string)
|
||||||
|
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||||
|
|
||||||
const APP_ALIASES: Record<string, string[]> = {
|
const APP_ALIASES: Record<string, string[]> = {
|
||||||
immich: ['immich_server', 'immich-server'],
|
immich: ['immich_server', 'immich-server'],
|
||||||
@ -286,14 +288,20 @@ const section = computed(() => {
|
|||||||
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||||
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
||||||
|
const backLabel = computed(() => {
|
||||||
|
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
|
||||||
|
return cloudStore.currentPath !== section.value.initialPath ? 'Back to Parent Folder' : 'Back to Cloud'
|
||||||
|
})
|
||||||
|
|
||||||
// Initialize native file browser when entering a native-UI section
|
// Initialize native file browser when entering a native-UI section
|
||||||
watch([useNativeUI, section], async ([native, sec]) => {
|
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||||
if (native && sec) {
|
if (native && sec) {
|
||||||
cloudStore.reset()
|
if (cloudStore.currentPath !== path) {
|
||||||
|
cloudStore.reset()
|
||||||
|
}
|
||||||
const ok = await cloudStore.init()
|
const ok = await cloudStore.init()
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await cloudStore.navigate(sec.initialPath)
|
await cloudStore.navigate(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
@ -370,7 +378,20 @@ function openExternal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function navigateCloudPath(path: string) {
|
||||||
|
const target = normalizeCloudPath(path, section.value?.initialPath || '/')
|
||||||
|
await router.push({
|
||||||
|
name: 'cloud-folder',
|
||||||
|
params: { folderId: folderId.value },
|
||||||
|
query: target === section.value?.initialPath ? {} : { path: target },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
|
||||||
|
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push('/dashboard/cloud')
|
router.push('/dashboard/cloud')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -63,6 +63,13 @@
|
|||||||
No credentials yet. Issue one above or receive one from a peer.
|
No credentials yet. Issue one above or receive one from a peer.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
|
<div v-if="loadingCreds" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5" 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>
|
||||||
|
Refreshing credentials...
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="cred in credentials"
|
v-for="cred in credentials"
|
||||||
:key="cred.id"
|
:key="cred.id"
|
||||||
@ -115,7 +122,7 @@
|
|||||||
<!-- Credential Detail Modal -->
|
<!-- Credential Detail Modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
|
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
|
||||||
<div class="fixed inset-0 bg-black/10 backdrop-blur-md"></div>
|
<div class="fixed inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
||||||
@ -403,6 +410,8 @@ async function copyCredentialJson() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadIdentities(), loadCredentials()])
|
await Promise.all([loadIdentities(), loadCredentials()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineExpose({ credentials, loadCredentials })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -85,7 +85,7 @@
|
|||||||
<div :key="route.path" class="view-wrapper">
|
<div :key="route.path" class="view-wrapper">
|
||||||
<div
|
<div
|
||||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||||
:class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
|
:class="['h-full dashboard-scroll-panel mobile-scroll-pad', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
|
||||||
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
||||||
class="mobile-safe-top"
|
class="mobile-safe-top"
|
||||||
>
|
>
|
||||||
@ -94,7 +94,7 @@
|
|||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
:class="[
|
:class="[
|
||||||
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top',
|
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
|
||||||
needsMobileBackButtonSpace
|
needsMobileBackButtonSpace
|
||||||
? 'mobile-scroll-pad-back'
|
? 'mobile-scroll-pad-back'
|
||||||
: 'mobile-scroll-pad'
|
: 'mobile-scroll-pad'
|
||||||
|
|||||||
@ -3,25 +3,50 @@
|
|||||||
<!-- Navigation Bar (always at top) -->
|
<!-- Navigation Bar (always at top) -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Desktop: tabs + categories + search -->
|
<!-- Desktop: tabs + categories + search -->
|
||||||
<div class="hidden md:flex mb-6 items-center gap-4">
|
<div ref="discoverHeaderRef" class="hidden md:flex mb-6 items-center gap-4 relative">
|
||||||
<div class="mode-switcher flex-shrink-0">
|
<div ref="discoverPrimaryRef" class="flex-shrink-0">
|
||||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
<div class="mode-switcher hidden md:inline-flex">
|
||||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||||
|
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mode-switcher flex-shrink-0">
|
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||||
<RouterLink
|
|
||||||
to="/dashboard/discover"
|
|
||||||
class="mode-switcher-btn"
|
|
||||||
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
|
||||||
>Discover</RouterLink>
|
|
||||||
<button
|
<button
|
||||||
v-for="category in categoriesWithApps"
|
v-for="section in appStoreSections"
|
||||||
:key="category.id"
|
:key="section.id"
|
||||||
@click="navigateToMarketplace(category.id)"
|
@click="selectDiscoverCategory(section.id)"
|
||||||
class="mode-switcher-btn"
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': section.id === 'discover' }"
|
||||||
>
|
>
|
||||||
{{ category.name }}
|
{{ section.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||||
|
<label class="sr-only" for="discover-category-select">App Store category</label>
|
||||||
|
<select
|
||||||
|
id="discover-category-select"
|
||||||
|
class="segmented-select-control"
|
||||||
|
value="discover"
|
||||||
|
@change="selectDiscoverCategory(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="section in appStoreSections"
|
||||||
|
:key="section.id"
|
||||||
|
:value="section.id"
|
||||||
|
>
|
||||||
|
{{ section.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div ref="discoverCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||||
|
<button
|
||||||
|
v-for="section in appStoreSections"
|
||||||
|
:key="section.id"
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{{ section.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@ -29,7 +54,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search apps..."
|
placeholder="Search apps..."
|
||||||
aria-label="Search apps"
|
aria-label="Search apps"
|
||||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -40,14 +65,14 @@
|
|||||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||||
<button class="mobile-category-pill mobile-category-pill-active" type="button">Discover</button>
|
|
||||||
<button
|
<button
|
||||||
v-for="category in categoriesWithApps"
|
v-for="section in appStoreSections"
|
||||||
:key="category.id"
|
:key="section.id"
|
||||||
@click="navigateToMarketplace(category.id)"
|
@click="selectDiscoverCategory(section.id)"
|
||||||
class="mobile-category-pill"
|
class="mobile-category-pill"
|
||||||
|
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
|
||||||
type="button"
|
type="button"
|
||||||
>{{ category.name }}</button>
|
>{{ section.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@ -193,6 +218,8 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||||
|
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||||
import AppGrid from './discover/AppGrid.vue'
|
import AppGrid from './discover/AppGrid.vue'
|
||||||
@ -211,19 +238,18 @@ const selectedCategory = ref('all')
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const bitcoinPruned = ref(false)
|
const bitcoinPruned = ref(false)
|
||||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||||
|
const discoverHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
const discoverPrimaryRef = ref<HTMLElement | null>(null)
|
||||||
|
const discoverCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||||
|
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||||
|
discoverHeaderRef,
|
||||||
|
discoverPrimaryRef,
|
||||||
|
discoverCategoryProbeRef,
|
||||||
|
144
|
||||||
|
)
|
||||||
|
|
||||||
const categories = computed(() => [
|
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||||
{ id: 'all', name: 'All' },
|
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||||
{ id: 'community', name: 'Community' },
|
|
||||||
{ id: 'nostr', name: 'Nostr' },
|
|
||||||
{ id: 'commerce', name: 'Commerce' },
|
|
||||||
{ id: 'money', name: 'Money' },
|
|
||||||
{ id: 'data', name: 'Data' },
|
|
||||||
{ id: 'home', name: 'Home' },
|
|
||||||
{ id: 'networking', name: 'Networking' },
|
|
||||||
{ id: 'l484', name: 'L484' },
|
|
||||||
{ id: 'other', name: 'Other' }
|
|
||||||
])
|
|
||||||
|
|
||||||
// Installation state — uses global store so it persists across navigation.
|
// Installation state — uses global store so it persists across navigation.
|
||||||
// The store's watcher (stores/server.ts) handles install-progress updates
|
// The store's watcher (stores/server.ts) handles install-progress updates
|
||||||
@ -236,6 +262,14 @@ function navigateToMarketplace(categoryId: string) {
|
|||||||
router.push({ name: 'marketplace', query: { category: categoryId } })
|
router.push({ name: 'marketplace', query: { category: categoryId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectDiscoverCategory(categoryId: string) {
|
||||||
|
if (categoryId === 'discover') {
|
||||||
|
router.push('/dashboard/discover')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigateToMarketplace(categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
// Community & Nostr marketplace state
|
// Community & Nostr marketplace state
|
||||||
const loadingCommunity = ref(false)
|
const loadingCommunity = ref(false)
|
||||||
const communityError = ref('')
|
const communityError = ref('')
|
||||||
@ -303,14 +337,6 @@ const allApps = computed(() => {
|
|||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoriesWithApps = computed(() => {
|
|
||||||
const apps = allApps.value
|
|
||||||
return categories.value.filter(cat => {
|
|
||||||
if (cat.id === 'all') return apps.length > 0
|
|
||||||
return apps.some(app => app.category === cat.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let apps = allApps.value
|
let apps = allApps.value
|
||||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||||
|
|||||||
@ -371,14 +371,23 @@ function isOnlineCheck(node: FederatedNode): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadNodes() {
|
async function loadNodes() {
|
||||||
|
return loadNodesWithOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
|
||||||
|
const showLoader = options.showLoader ?? nodes.value.length === 0
|
||||||
|
const surfaceErrors = options.surfaceErrors ?? true
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
if (showLoader) loading.value = true
|
||||||
const result = await rpcClient.federationListNodes()
|
const result = await rpcClient.federationListNodes()
|
||||||
nodes.value = result.nodes
|
nodes.value = result.nodes
|
||||||
|
error.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
if (surfaceErrors) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (showLoader) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -540,7 +549,7 @@ async function rotateDid(password: string) {
|
|||||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadNodes()
|
loadNodesWithOptions({ showLoader: true })
|
||||||
loadDwnStatus()
|
loadDwnStatus()
|
||||||
loadDiscoveryState()
|
loadDiscoveryState()
|
||||||
loadPendingRequests()
|
loadPendingRequests()
|
||||||
@ -552,7 +561,7 @@ onMounted(async () => {
|
|||||||
// Self DID not available
|
// Self DID not available
|
||||||
}
|
}
|
||||||
autoRefreshTimer = setInterval(() => {
|
autoRefreshTimer = setInterval(() => {
|
||||||
loadNodes()
|
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
||||||
loadPendingRequests()
|
loadPendingRequests()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -22,18 +22,27 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="hidden md:block mb-8">
|
<div class="hidden md:block mb-8">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-4 min-[1440px]:flex-row min-[1440px]:items-center min-[1440px]:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
|
||||||
<p class="text-white/70">Beta Telemetry — monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
<p class="text-white/70">Beta Telemetry — monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="grid grid-cols-3 gap-2 min-[1440px]:flex min-[1440px]:items-center">
|
||||||
<span v-if="fleet.autoRefresh.value" class="text-xs text-white/40">Auto-refresh 60s</span>
|
<div class="monitoring-stat-card monitoring-stat-card-compact col-span-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap min-[1440px]:col-span-1 min-[1440px]:min-w-[220px]">
|
||||||
|
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||||
|
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||||
|
></span>
|
||||||
|
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.toggleAutoRefresh">
|
<button class="glass-button text-sm px-4 py-2" @click="fleet.toggleAutoRefresh">
|
||||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">
|
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||||
Refresh
|
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
|
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
|
||||||
Export JSON
|
Export JSON
|
||||||
@ -46,15 +55,37 @@
|
|||||||
<div class="md:hidden mb-6">
|
<div class="md:hidden mb-6">
|
||||||
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
|
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
|
||||||
<p class="text-white/60 text-sm mb-3">Monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
<p class="text-white/60 text-sm mb-3">Monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||||
|
<div class="monitoring-stat-card monitoring-stat-card-compact mb-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap">
|
||||||
|
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||||
|
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||||
|
></span>
|
||||||
|
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.refreshAll">Refresh</button>
|
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.toggleAutoRefresh">
|
||||||
|
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||||
|
</button>
|
||||||
|
<button class="glass-button text-xs px-3 py-2 flex-1 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||||
|
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||||
|
</button>
|
||||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.exportFleetData">Export</button>
|
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.exportFleetData">Export</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
|
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
|
||||||
<div class="text-white/50 text-sm">Loading fleet data...</div>
|
<div class="glass-card p-8 max-w-md text-center">
|
||||||
|
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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>
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-2">Loading fleet data</h3>
|
||||||
|
<p class="text-white/60 text-sm">Checking beta telemetry reports from connected nodes.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
@ -76,6 +107,14 @@
|
|||||||
:avg-disk="fleet.avgDisk.value"
|
:avg-disk="fleet.avgDisk.value"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-if="fleet.refreshing.value && fleet.nodes.value.length > 0" class="glass-card p-3 mb-4 text-sm text-white/60 flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-4 w-4 text-white/50" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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>
|
||||||
|
Refreshing fleet telemetry...
|
||||||
|
</div>
|
||||||
|
|
||||||
<FleetNodeGrid
|
<FleetNodeGrid
|
||||||
:nodes="fleet.nodes.value"
|
:nodes="fleet.nodes.value"
|
||||||
:sorted-nodes="fleet.sortedNodes.value"
|
:sorted-nodes="fleet.sortedNodes.value"
|
||||||
|
|||||||
@ -111,6 +111,13 @@
|
|||||||
>
|
>
|
||||||
{{ t('goalDetail.openAndConfigure') }}
|
{{ t('goalDetail.openAndConfigure') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="step.action === 'verify'"
|
||||||
|
@click="completeVerifyStep(step)"
|
||||||
|
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ t('goalDetail.checkAndContinue') }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else-if="step.action === 'info'"
|
v-else-if="step.action === 'info'"
|
||||||
@click="completeInfoStep(step)"
|
@click="completeInfoStep(step)"
|
||||||
@ -167,6 +174,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useGoalStore } from '@/stores/goals'
|
import { useGoalStore } from '@/stores/goals'
|
||||||
import { getGoalById } from '@/data/goals'
|
import { getGoalById } from '@/data/goals'
|
||||||
import type { GoalStep } from '@/types/goals'
|
import type { GoalStep } from '@/types/goals'
|
||||||
|
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||||
|
|
||||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||||
const APP_ICON_MAP: Record<string, string> = {
|
const APP_ICON_MAP: Record<string, string> = {
|
||||||
@ -287,10 +295,7 @@ async function installApp(step: GoalStep) {
|
|||||||
if (!step.appId) return
|
if (!step.appId) return
|
||||||
isInstalling.value = true
|
isInstalling.value = true
|
||||||
|
|
||||||
// Start goal tracking if not already started
|
ensureGoalStarted()
|
||||||
if (!goalStore.progress[goalId.value]) {
|
|
||||||
goalStore.startGoal(goalId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await appStore.installPackage(step.appId, '', 'latest')
|
await appStore.installPackage(step.appId, '', 'latest')
|
||||||
@ -303,19 +308,28 @@ async function installApp(step: GoalStep) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openConfigureStep(step: GoalStep) {
|
function openConfigureStep(step: GoalStep) {
|
||||||
// Mark step as complete when user acknowledges they've configured
|
ensureGoalStarted()
|
||||||
goalStore.completeStep(goalId.value, step.id)
|
goalStore.completeStep(goalId.value, step.id)
|
||||||
// If there's an app to open, navigate to it
|
const targetPath = goalStepTargetPath(step)
|
||||||
if (step.appId) {
|
if (targetPath) {
|
||||||
router.push(`/dashboard/apps/${step.appId}`)
|
router.push(targetPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completeVerifyStep(step: GoalStep) {
|
||||||
|
ensureGoalStarted()
|
||||||
|
goalStore.completeStep(goalId.value, step.id)
|
||||||
|
}
|
||||||
|
|
||||||
function completeInfoStep(step: GoalStep) {
|
function completeInfoStep(step: GoalStep) {
|
||||||
|
ensureGoalStarted()
|
||||||
|
goalStore.completeStep(goalId.value, step.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGoalStarted() {
|
||||||
if (!goalStore.progress[goalId.value]) {
|
if (!goalStore.progress[goalId.value]) {
|
||||||
goalStore.startGoal(goalId.value)
|
goalStore.startGoal(goalId.value)
|
||||||
}
|
}
|
||||||
goalStore.completeStep(goalId.value, step.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
|||||||
@ -86,8 +86,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
|
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
|
||||||
<button v-for="app in quickLaunchApps" :key="app.id" @click="useAppLauncherStore().openSession(app.id)" class="group" :title="app.name">
|
<button v-for="app in quickLaunchApps" :key="app.id" @click="useAppLauncherStore().openSession(app.id)" class="group" :title="app.name">
|
||||||
<div class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center" :style="app.bg ? { background: app.bg } : {}" :class="{ 'bg-white/5': !app.bg }">
|
<div class="w-14 h-14 rounded-xl overflow-hidden transition-all group-hover:-translate-y-1 group-hover:shadow-lg flex items-center justify-center">
|
||||||
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
|
<img :src="app.icon" :alt="app.name" class="w-full h-full rounded-xl archy-app-icon" @error="handleImageError" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -125,8 +125,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- App Store Recommendations -->
|
||||||
|
<div
|
||||||
|
v-if="homeRecommendedApps.length > 0"
|
||||||
|
data-controller-container
|
||||||
|
tabindex="0"
|
||||||
|
class="home-card controller-focusable"
|
||||||
|
:class="{ 'home-card-animate': animateCards }"
|
||||||
|
style="--card-stagger: 2"
|
||||||
|
>
|
||||||
|
<div class="home-card-shell">
|
||||||
|
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||||
|
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||||
|
<div class="home-card-text">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
|
||||||
|
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||||
|
<button
|
||||||
|
v-for="app in homeRecommendedApps"
|
||||||
|
:key="app.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
|
||||||
|
@click="viewRecommendedApp(app)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="app.icon"
|
||||||
|
:src="app.icon"
|
||||||
|
:alt="app.title || app.id"
|
||||||
|
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
|
||||||
|
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||||
|
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Network Overview -->
|
<!-- Network Overview -->
|
||||||
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 2">
|
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
|
||||||
<div class="home-card-shell">
|
<div class="home-card-shell">
|
||||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||||
@ -179,6 +229,33 @@
|
|||||||
@open-in-mempool="openInMempool"
|
@open-in-mempool="openInMempool"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Quick Start Goals -->
|
||||||
|
<div
|
||||||
|
v-if="showQuickStart"
|
||||||
|
class="home-card transition-opacity duration-300"
|
||||||
|
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||||
|
style="--card-stagger: 5"
|
||||||
|
>
|
||||||
|
<div class="home-card-shell">
|
||||||
|
<div class="home-card-inner px-6 py-6 flex flex-col h-full min-h-0">
|
||||||
|
<div class="flex items-start justify-between mb-2 shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
||||||
|
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="dismissQuickStart" aria-label="Dismiss Quick Start" class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1" title="Dismiss">
|
||||||
|
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-3 mt-auto">
|
||||||
|
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
|
||||||
|
<span>{{ goal.title }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Stats -->
|
<!-- System Stats -->
|
||||||
<HomeSystemCard
|
<HomeSystemCard
|
||||||
:animate="animateCards"
|
:animate="animateCards"
|
||||||
@ -187,33 +264,6 @@
|
|||||||
:uptime-display="systemUptimeDisplay"
|
:uptime-display="systemUptimeDisplay"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Start Goals -->
|
|
||||||
<div
|
|
||||||
v-if="homeTab === 'dashboard' && showQuickStart"
|
|
||||||
class="home-card transition-opacity duration-300"
|
|
||||||
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
|
||||||
style="--card-stagger: 5"
|
|
||||||
>
|
|
||||||
<div class="home-card-shell">
|
|
||||||
<div class="home-card-inner px-6 py-6">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
|
||||||
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<button @click="dismissQuickStart" aria-label="Dismiss Quick Start" class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1" title="Dismiss">
|
|
||||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
|
|
||||||
<span>{{ goal.title }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Chat Mode -->
|
<!-- Chat Mode -->
|
||||||
@ -246,6 +296,11 @@ import { GOALS } from '@/data/goals'
|
|||||||
import EasyHome from '@/components/EasyHome.vue'
|
import EasyHome from '@/components/EasyHome.vue'
|
||||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { getAppUsage } from '@/utils/appUsage'
|
||||||
|
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
|
||||||
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
|
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||||
|
import { getHomeRecommendedApps } from './home/homeRecommendations'
|
||||||
import HomeWalletCard from './home/HomeWalletCard.vue'
|
import HomeWalletCard from './home/HomeWalletCard.vue'
|
||||||
import HomeSystemCard from './home/HomeSystemCard.vue'
|
import HomeSystemCard from './home/HomeSystemCard.vue'
|
||||||
import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
||||||
@ -264,6 +319,7 @@ const QUICK_START_RESHOW_LOGINS = 5
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const homeStatus = useHomeStatusStore()
|
const homeStatus = useHomeStatusStore()
|
||||||
const loginTransition = useLoginTransitionStore()
|
const loginTransition = useLoginTransitionStore()
|
||||||
|
const { setCurrentApp } = useMarketplaceApp()
|
||||||
|
|
||||||
const LINE1 = t('home.title')
|
const LINE1 = t('home.title')
|
||||||
const LINE2 = t('home.subtitle')
|
const LINE2 = t('home.subtitle')
|
||||||
@ -306,11 +362,40 @@ const packages = computed(() => store.packages)
|
|||||||
const appCount = computed(() => Object.keys(packages.value || {}).length)
|
const appCount = computed(() => Object.keys(packages.value || {}).length)
|
||||||
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
|
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
|
||||||
|
|
||||||
const quickLaunchApps = [
|
const quickLaunchApps = computed(() => {
|
||||||
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
|
const usage = getAppUsage()
|
||||||
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
|
return Object.entries(packages.value || {})
|
||||||
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
|
.filter(([id, pkg]) => !isServicePackage(id, pkg) && !isWebsitePackage(id, pkg))
|
||||||
]
|
.map(([id, pkg]) => ({
|
||||||
|
id,
|
||||||
|
name: pkg.manifest?.title || id,
|
||||||
|
icon: resolveAppIcon(id, pkg),
|
||||||
|
state: pkg.state,
|
||||||
|
usage: usage[id]?.count || 0,
|
||||||
|
lastLaunchedAt: usage[id]?.lastLaunchedAt || 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.usage !== a.usage) return b.usage - a.usage
|
||||||
|
if (b.lastLaunchedAt !== a.lastLaunchedAt) return b.lastLaunchedAt - a.lastLaunchedAt
|
||||||
|
if ((b.state === PackageState.Running ? 1 : 0) !== (a.state === PackageState.Running ? 1 : 0)) {
|
||||||
|
return (b.state === PackageState.Running ? 1 : 0) - (a.state === PackageState.Running ? 1 : 0)
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppList(), packages.value, 3))
|
||||||
|
|
||||||
|
function viewRecommendedApp(app: MarketplaceApp) {
|
||||||
|
setCurrentApp(app)
|
||||||
|
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketplaceDescription(app: MarketplaceApp) {
|
||||||
|
if (typeof app.description === 'string') return app.description
|
||||||
|
return app.description?.short || app.description?.long || ''
|
||||||
|
}
|
||||||
|
|
||||||
// Network card data
|
// Network card data
|
||||||
const torConnected = computed(() => {
|
const torConnected = computed(() => {
|
||||||
|
|||||||
@ -3,27 +3,51 @@
|
|||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Desktop: tabs + categories + search -->
|
<!-- Desktop: tabs + categories + search -->
|
||||||
<div class="hidden md:flex mb-4 items-center gap-4">
|
<div ref="marketplaceHeaderRef" class="hidden md:flex mb-4 items-center gap-4 relative">
|
||||||
<div class="mode-switcher flex-shrink-0">
|
<div ref="marketplacePrimaryRef" class="flex-shrink-0">
|
||||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
<div class="mode-switcher hidden md:inline-flex">
|
||||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||||
|
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mode-switcher flex-shrink-0">
|
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||||
<RouterLink
|
|
||||||
to="/dashboard/discover"
|
|
||||||
class="mode-switcher-btn"
|
|
||||||
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
|
||||||
>Discover</RouterLink>
|
|
||||||
<button
|
<button
|
||||||
v-for="category in categoriesWithApps"
|
v-for="section in appStoreSections"
|
||||||
:key="category.id"
|
:key="section.id"
|
||||||
@click="selectCategory(category.id)"
|
@click="selectAppStoreSection(section.id)"
|
||||||
class="mode-switcher-btn"
|
class="mode-switcher-btn"
|
||||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
:class="{ 'mode-switcher-btn-active': selectedCategory === section.id }"
|
||||||
>
|
>
|
||||||
{{ category.name }}
|
{{ section.name }}
|
||||||
<span v-if="category.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
|
<span v-if="section.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||||
|
<label class="sr-only" for="marketplace-category-select">App Store category</label>
|
||||||
|
<select
|
||||||
|
id="marketplace-category-select"
|
||||||
|
:value="selectedCategory"
|
||||||
|
class="segmented-select-control"
|
||||||
|
@change="selectAppStoreSection(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="section in appStoreSections"
|
||||||
|
:key="section.id"
|
||||||
|
:value="section.id"
|
||||||
|
>
|
||||||
|
{{ section.name }}{{ section.id === 'nostr' && nostrApps.length > 0 ? ` +${nostrApps.length}` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div ref="marketplaceCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||||
|
<button
|
||||||
|
v-for="section in appStoreSections"
|
||||||
|
:key="section.id"
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{{ section.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@ -31,7 +55,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('marketplace.searchPlaceholder')"
|
:placeholder="t('marketplace.searchPlaceholder')"
|
||||||
:aria-label="t('marketplace.searchApps')"
|
:aria-label="t('marketplace.searchApps')"
|
||||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -43,18 +67,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||||
<button
|
<button
|
||||||
@click="router.push({ name: 'discover' })"
|
v-for="section in appStoreSections"
|
||||||
|
:key="section.id"
|
||||||
|
@click="selectAppStoreSection(section.id)"
|
||||||
class="mobile-category-pill"
|
class="mobile-category-pill"
|
||||||
|
:class="{ 'mobile-category-pill-active': selectedCategory === section.id }"
|
||||||
type="button"
|
type="button"
|
||||||
>Discover</button>
|
>{{ section.name }}</button>
|
||||||
<button
|
|
||||||
v-for="category in categoriesWithApps"
|
|
||||||
:key="category.id"
|
|
||||||
@click="selectCategory(category.id)"
|
|
||||||
class="mobile-category-pill"
|
|
||||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
|
||||||
type="button"
|
|
||||||
>{{ category.name }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@ -76,6 +95,26 @@
|
|||||||
|
|
||||||
<!-- Apps Grid -->
|
<!-- Apps Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<template v-if="(loadingCommunity || nostrLoading) && filteredApps.length === 0">
|
||||||
|
<div
|
||||||
|
v-for="index in 6"
|
||||||
|
:key="`loading-${index}`"
|
||||||
|
class="glass-card p-6 flex flex-col app-card-skeleton"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4 mb-4">
|
||||||
|
<div class="app-card-skeleton-icon"></div>
|
||||||
|
<div class="flex-1 min-w-0 pt-1">
|
||||||
|
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||||
|
<div class="app-card-skeleton-line w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||||
|
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||||
|
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||||
|
<div class="app-card-skeleton-button mt-auto"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<MarketplaceAppCard
|
<MarketplaceAppCard
|
||||||
v-for="(app, index) in filteredApps"
|
v-for="(app, index) in filteredApps"
|
||||||
:key="app.id"
|
:key="app.id"
|
||||||
@ -97,15 +136,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="filteredApps.length === 0" class="text-center py-12">
|
<div v-if="filteredApps.length === 0 && !(loadingCommunity || nostrLoading)" class="text-center py-12">
|
||||||
<div v-if="loadingCommunity || nostrLoading" class="flex flex-col items-center gap-4">
|
<div v-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
|
||||||
<svg class="animate-spin h-12 w-12 text-blue-400" xmlns="http://www.w3.org/2000/svg" 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>
|
|
||||||
<p class="text-white/70">{{ nostrLoading ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
|
|
||||||
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
||||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
||||||
@ -132,6 +164,8 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||||
|
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||||
import {
|
import {
|
||||||
type MarketplaceApp,
|
type MarketplaceApp,
|
||||||
@ -155,19 +189,8 @@ const toast = useToast()
|
|||||||
// Category state — read initial value from query param (set by Discover page navigation)
|
// Category state — read initial value from query param (set by Discover page navigation)
|
||||||
const selectedCategory = ref((route.query.category as string) || 'all')
|
const selectedCategory = ref((route.query.category as string) || 'all')
|
||||||
|
|
||||||
const categories = computed(() => [
|
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||||
{ id: 'all', name: t('marketplace.all') },
|
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||||
{ id: 'community', name: t('marketplace.community') },
|
|
||||||
{ id: 'nostr', name: 'Nostr' },
|
|
||||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
|
||||||
{ id: 'money', name: t('marketplace.money') },
|
|
||||||
{ id: 'data', name: t('marketplace.data') },
|
|
||||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
|
||||||
{ id: 'car', name: t('marketplace.auto') },
|
|
||||||
{ id: 'networking', name: t('marketplace.networking') },
|
|
||||||
{ id: 'l484', name: 'L484' },
|
|
||||||
{ id: 'other', name: t('marketplace.other') }
|
|
||||||
])
|
|
||||||
|
|
||||||
// Installation state — uses global store so it persists across navigation
|
// Installation state — uses global store so it persists across navigation
|
||||||
const installingApps = server.installingApps
|
const installingApps = server.installingApps
|
||||||
@ -186,6 +209,14 @@ function selectCategory(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectAppStoreSection(id: string) {
|
||||||
|
if (id === 'discover') {
|
||||||
|
router.push('/dashboard/discover')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectCategory(id)
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => route.query.category, (category) => {
|
watch(() => route.query.category, (category) => {
|
||||||
const next = typeof category === 'string' && category ? category : 'all'
|
const next = typeof category === 'string' && category ? category : 'all'
|
||||||
selectedCategory.value = next
|
selectedCategory.value = next
|
||||||
@ -200,6 +231,15 @@ const communityError = ref('')
|
|||||||
const communityApps = ref<MarketplaceApp[]>([])
|
const communityApps = ref<MarketplaceApp[]>([])
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const bitcoinPruned = ref(false)
|
const bitcoinPruned = ref(false)
|
||||||
|
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
|
||||||
|
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||||
|
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||||
|
marketplaceHeaderRef,
|
||||||
|
marketplacePrimaryRef,
|
||||||
|
marketplaceCategoryProbeRef,
|
||||||
|
144
|
||||||
|
)
|
||||||
|
|
||||||
// Nostr community marketplace state
|
// Nostr community marketplace state
|
||||||
const nostrApps = ref<MarketplaceApp[]>([])
|
const nostrApps = ref<MarketplaceApp[]>([])
|
||||||
@ -270,14 +310,6 @@ const allApps = computed(() => {
|
|||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoriesWithApps = computed(() => {
|
|
||||||
const apps = allApps.value
|
|
||||||
return categories.value.filter(cat => {
|
|
||||||
if (cat.id === 'all') return apps.length > 0
|
|
||||||
return apps.some(app => app.category === cat.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let apps = allApps.value
|
let apps = allApps.value
|
||||||
|
|
||||||
@ -500,4 +532,49 @@ async function installCommunityApp(app: MarketplaceApp) {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton {
|
||||||
|
min-height: 255px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-icon,
|
||||||
|
.app-card-skeleton-line,
|
||||||
|
.app-card-skeleton-button {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-icon::after,
|
||||||
|
.app-card-skeleton-line::after,
|
||||||
|
.app-card-skeleton-button::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||||
|
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-icon {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-line {
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-button {
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -201,20 +201,18 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Screenshots Gallery -->
|
<!-- Screenshots Gallery -->
|
||||||
<div class="glass-card p-6">
|
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div
|
<img
|
||||||
v-for="i in 4"
|
v-for="screenshot in screenshots"
|
||||||
:key="i"
|
:key="screenshot.src"
|
||||||
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
|
:src="screenshot.src"
|
||||||
>
|
:alt="screenshot.alt"
|
||||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
loading="lazy"
|
||||||
</svg>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@ -443,6 +441,26 @@ const longDescription = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const screenshots = computed(() => normalizeScreenshots(app.value?.screenshots))
|
||||||
|
|
||||||
|
function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefined) {
|
||||||
|
if (!Array.isArray(items)) return []
|
||||||
|
return items
|
||||||
|
.map((item, index) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const src = item.trim()
|
||||||
|
return src ? { src, alt: `${app.value?.title || 'App'} screenshot ${index + 1}` } : null
|
||||||
|
}
|
||||||
|
const src = item.src?.trim()
|
||||||
|
if (!src) return null
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
alt: item.alt?.trim() || `${app.value?.title || 'App'} screenshot ${index + 1}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||||
|
}
|
||||||
|
|
||||||
// Placeholder features
|
// Placeholder features
|
||||||
const features = computed(() => {
|
const features = computed(() => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -15,10 +15,12 @@ const transport = useTransportStore()
|
|||||||
|
|
||||||
// Responsive layout breakpoints
|
// Responsive layout breakpoints
|
||||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||||
|
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
|
||||||
const isMobile = ref(window.innerWidth < 1280)
|
const isMobile = ref(window.innerWidth < 1280)
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
isWideDesktop.value = window.innerWidth >= 1536
|
isWideDesktop.value = window.innerWidth >= 1536
|
||||||
|
isVeryWideDesktop.value = window.innerWidth >= 2560 && window.innerHeight >= 1200
|
||||||
isMobile.value = window.innerWidth < 1280
|
isMobile.value = window.innerWidth < 1280
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,15 +253,21 @@ const showChatPanel = computed(() =>
|
|||||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||||
)
|
)
|
||||||
const showBitcoinPanel = computed(() => {
|
const showBitcoinPanel = computed(() => {
|
||||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
|
if (isVeryWideDesktop.value) return true
|
||||||
|
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||||
|
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
|
||||||
return activeTab.value === 'bitcoin'
|
return activeTab.value === 'bitcoin'
|
||||||
})
|
})
|
||||||
const showDeadmanPanel = computed(() => {
|
const showDeadmanPanel = computed(() => {
|
||||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
|
if (isVeryWideDesktop.value) return true
|
||||||
|
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||||
|
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
|
||||||
return activeTab.value === 'deadman'
|
return activeTab.value === 'deadman'
|
||||||
})
|
})
|
||||||
const showMapPanel = computed(() => {
|
const showMapPanel = computed(() => {
|
||||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'map'
|
if (isVeryWideDesktop.value) return true
|
||||||
|
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||||
|
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
|
||||||
return activeTab.value === 'map'
|
return activeTab.value === 'map'
|
||||||
})
|
})
|
||||||
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||||
@ -1270,7 +1278,7 @@ function isImageMime(mime?: string): boolean {
|
|||||||
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
|
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
|
||||||
|
|
||||||
<!-- Responsive column layout -->
|
<!-- Responsive column layout -->
|
||||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
|
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
|
||||||
<!-- LEFT COLUMN: Status + Peers -->
|
<!-- LEFT COLUMN: Status + Peers -->
|
||||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||||
<!-- Device Status -->
|
<!-- Device Status -->
|
||||||
@ -1727,8 +1735,7 @@ function isImageMime(mime?: string): boolean {
|
|||||||
|
|
||||||
<!-- Tools panels (3rd column on wide screens) -->
|
<!-- Tools panels (3rd column on wide screens) -->
|
||||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||||
<!-- Tools tab bar (wide desktop only) -->
|
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
|
||||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||||
Off-Grid Bitcoin
|
Off-Grid Bitcoin
|
||||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||||
@ -1742,10 +1749,9 @@ function isImageMime(mime?: string): boolean {
|
|||||||
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
|
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
|
||||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||||
|
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
<p class="text-base sm:text-xl text-white/80">How would you like to get started?</p>
|
<p class="text-base sm:text-xl text-white/80">How would you like to get started?</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-6 px-3 sm:px-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-6 px-3 sm:px-4 max-w-[760px] mx-auto w-full">
|
||||||
<!-- Fresh Start -->
|
<!-- Fresh Start -->
|
||||||
<button
|
<button
|
||||||
@click="selectOption('fresh')"
|
@click="selectOption('fresh')"
|
||||||
@ -52,22 +52,6 @@
|
|||||||
Enter your 24-word recovery phrase
|
Enter your 24-word recovery phrase
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Connect Existing (Coming Soon) -->
|
|
||||||
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
|
|
||||||
<div class="mb-3 sm:mb-4">
|
|
||||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Connect Existing</h3>
|
|
||||||
<p class="text-white/70 text-xs sm:text-sm">
|
|
||||||
Connect to an existing Archipelago server
|
|
||||||
</p>
|
|
||||||
<span class="text-xs text-white/50 mt-1 block">(Coming Soon)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="glass-card p-8 text-center">
|
<div v-if="loading && catalogItems.length === 0" class="glass-card p-8 text-center">
|
||||||
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<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>
|
<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>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-else-if="catalogError" class="glass-card p-6">
|
<div v-else-if="catalogError && catalogItems.length === 0" class="glass-card p-6">
|
||||||
<div class="alert-error mb-4">{{ catalogError }}</div>
|
<div class="alert-error mb-4">{{ catalogError }}</div>
|
||||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
@ -67,12 +67,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Grid -->
|
<!-- File Grid -->
|
||||||
<div v-else-if="catalogItems.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-if="catalogItems.length > 0" class="space-y-3">
|
||||||
<div
|
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||||
v-for="item in catalogItems"
|
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||||
:key="item.id"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
class="glass-card overflow-hidden"
|
<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>
|
||||||
|
Refreshing peer files...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="catalogError" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
|
||||||
|
{{ catalogError }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="item in catalogItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="glass-card overflow-hidden"
|
||||||
|
>
|
||||||
<!-- Media preview (images / videos / audio) -->
|
<!-- Media preview (images / videos / audio) -->
|
||||||
<div
|
<div
|
||||||
v-if="isMediaMime(item.mime_type)"
|
v-if="isMediaMime(item.mime_type)"
|
||||||
@ -192,6 +203,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -313,9 +325,9 @@ onMounted(async () => {
|
|||||||
async function loadCatalog() {
|
async function loadCatalog() {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
|
const hadItems = catalogItems.value.length > 0
|
||||||
loading.value = true
|
loading.value = true
|
||||||
catalogError.value = ''
|
catalogError.value = ''
|
||||||
catalogItems.value = []
|
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||||
method: 'content.browse-peer',
|
method: 'content.browse-peer',
|
||||||
@ -325,6 +337,7 @@ async function loadCatalog() {
|
|||||||
catalogItems.value = result?.items ?? []
|
catalogItems.value = result?.items ?? []
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||||
|
if (!hadItems) catalogItems.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -563,6 +576,8 @@ function triggerDownload(base64Data: string, item: CatalogItem) {
|
|||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ loadCatalog })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -80,6 +80,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div v-if="networkRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5" 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>
|
||||||
|
Refreshing network...
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||||
@ -135,46 +142,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
|
|
||||||
Manage Local Network
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web3 Card -->
|
|
||||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
|
||||||
<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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h2 class="text-xl font-semibold text-white mb-2">Web3</h2>
|
|
||||||
<p class="text-white/70 text-sm mb-4">Decentralized web hosting and services</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3 flex-1 min-h-0">
|
|
||||||
<div v-for="item in ['Hosted Websites', 'SSL Certificates', 'IPFS Storage', 'ENS Domains']" :key="item" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
|
||||||
<span class="text-white/80 text-sm">{{ item }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
|
|
||||||
Manage Web3 Services
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FIPS Mesh (full card) -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<FipsNetworkCard />
|
<FipsNetworkCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||||
<!-- VPN Card -->
|
<!-- VPN Card -->
|
||||||
<div class="glass-card p-6 transition-all hover:-translate-y-1">
|
<div class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
@ -185,7 +160,7 @@
|
|||||||
<p class="text-xs text-white/50">Standalone WireGuard VPN</p>
|
<p class="text-xs text-white/50">Standalone WireGuard VPN</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
|
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-top glass-button px-4 py-2 text-sm">Add Device</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WireGuard Status -->
|
<!-- WireGuard Status -->
|
||||||
@ -221,10 +196,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||||
|
Add Device
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Network Interfaces (second column on desktop) -->
|
<!-- Network Interfaces (second column on desktop) -->
|
||||||
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||||
@ -233,7 +212,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="wifiAvailable"
|
v-if="wifiAvailable"
|
||||||
@click="showWifiModal = true"
|
@click="showWifiModal = true"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="responsive-card-actions-top px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Scan WiFi
|
Scan WiFi
|
||||||
</button>
|
</button>
|
||||||
@ -246,6 +225,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
<div v-if="interfacesRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5" 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>
|
||||||
|
Refreshing interfaces...
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="iface in physicalInterfaces"
|
v-for="iface in physicalInterfaces"
|
||||||
:key="iface.name"
|
:key="iface.name"
|
||||||
@ -266,6 +252,14 @@
|
|||||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="wifiAvailable"
|
||||||
|
@click="showWifiModal = true"
|
||||||
|
class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Scan WiFi
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- close VPN+Network 2-col grid -->
|
</div><!-- close VPN+Network 2-col grid -->
|
||||||
@ -413,6 +407,8 @@ const logCount = ref(0)
|
|||||||
|
|
||||||
// Network data
|
// Network data
|
||||||
const networkLoading = ref(true)
|
const networkLoading = ref(true)
|
||||||
|
const networkRefreshing = ref(false)
|
||||||
|
const networkHasLoaded = ref(false)
|
||||||
const networkData = ref({
|
const networkData = ref({
|
||||||
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
||||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||||
@ -449,7 +445,9 @@ async function loadFipsSummary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadNetworkData() {
|
async function loadNetworkData() {
|
||||||
networkLoading.value = true
|
const initialLoad = !networkHasLoaded.value
|
||||||
|
networkLoading.value = initialLoad
|
||||||
|
networkRefreshing.value = !initialLoad
|
||||||
try {
|
try {
|
||||||
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
||||||
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
||||||
@ -461,7 +459,11 @@ async function loadNetworkData() {
|
|||||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||||
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||||
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
|
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
|
||||||
} catch { /* keep defaults */ } finally { networkLoading.value = false }
|
} catch { /* keep existing/default values */ } finally {
|
||||||
|
networkHasLoaded.value = true
|
||||||
|
networkLoading.value = false
|
||||||
|
networkRefreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// VPN peer management
|
// VPN peer management
|
||||||
@ -548,6 +550,8 @@ interface NetworkInterface { name: string; type: string; state: string; mac: str
|
|||||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||||
|
|
||||||
const interfacesLoading = ref(true)
|
const interfacesLoading = ref(true)
|
||||||
|
const interfacesRefreshing = ref(false)
|
||||||
|
const interfacesHaveLoaded = ref(false)
|
||||||
const allInterfaces = ref<NetworkInterface[]>([])
|
const allInterfaces = ref<NetworkInterface[]>([])
|
||||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||||
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
||||||
@ -599,8 +603,11 @@ async function applyDnsConfig(customServers: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadInterfaces() {
|
async function loadInterfaces() {
|
||||||
interfacesLoading.value = true
|
const initialLoad = !interfacesHaveLoaded.value
|
||||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { allInterfaces.value = [] } finally { interfacesLoading.value = false }
|
const hadInterfaces = allInterfaces.value.length > 0
|
||||||
|
interfacesLoading.value = initialLoad
|
||||||
|
interfacesRefreshing.value = !initialLoad
|
||||||
|
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanWifi() {
|
async function scanWifi() {
|
||||||
@ -670,9 +677,10 @@ const availableAppsForTor = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadTorServices() {
|
async function loadTorServices() {
|
||||||
|
const hadServices = torServices.value.length > 0
|
||||||
torServicesLoading.value = true
|
torServicesLoading.value = true
|
||||||
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
|
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
|
||||||
catch { torServices.value = []; torDaemonRunning.value = false } finally { torServicesLoading.value = false }
|
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyTorAddress(address: string) {
|
async function copyTorAddress(address: string) {
|
||||||
@ -753,4 +761,16 @@ async function checkTorStatus() {
|
|||||||
|
|
||||||
const logsToast = ref('')
|
const logsToast = ref('')
|
||||||
function viewLogs() { logCount.value = 0; logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'; setTimeout(() => { logsToast.value = '' }, 6000) }
|
function viewLogs() { logCount.value = 0; logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'; setTimeout(() => { logsToast.value = '' }, 6000) }
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
allInterfaces,
|
||||||
|
interfacesRefreshing,
|
||||||
|
loadInterfaces,
|
||||||
|
loadNetworkData,
|
||||||
|
loadTorServices,
|
||||||
|
networkData,
|
||||||
|
networkRefreshing,
|
||||||
|
torServices,
|
||||||
|
torServicesLoading,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
83
neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts
Normal file
83
neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AppSession from '../AppSession.vue'
|
||||||
|
|
||||||
|
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
||||||
|
mockReplace: vi.fn(),
|
||||||
|
mockPush: vi.fn(),
|
||||||
|
mockWindowOpen: vi.fn(),
|
||||||
|
mockSuppress: vi.fn(),
|
||||||
|
mockResume: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({
|
||||||
|
params: { appId: 'gitea' },
|
||||||
|
query: { returnTo: '/dashboard/apps' },
|
||||||
|
fullPath: '/dashboard/apps/session/gitea',
|
||||||
|
}),
|
||||||
|
useRouter: () => ({ replace: mockReplace, push: mockPush }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/appLauncher', () => ({
|
||||||
|
useAppLauncherStore: () => ({ panelAppId: null }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({ data: { 'package-data': {} } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/screensaver', () => ({
|
||||||
|
useScreensaverStore: () => ({ suppress: mockSuppress, resume: mockResume }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../appSession/useAppIdentity', () => ({
|
||||||
|
useAppIdentity: () => ({
|
||||||
|
onIdentitySelected: vi.fn(),
|
||||||
|
onIframeLoadIdentity: vi.fn(),
|
||||||
|
handleIdentityRequest: vi.fn(),
|
||||||
|
getStoredIdentity: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../appSession/useNostrBridge', () => ({
|
||||||
|
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.stubGlobal('open', mockWindowOpen)
|
||||||
|
|
||||||
|
describe('AppSession mobile new-tab apps', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
|
value: 390,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { hostname: '192.168.1.228' },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => {
|
||||||
|
const wrapper = mount(AppSession, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Teleport: true,
|
||||||
|
AppSessionHeader: true,
|
||||||
|
NostrIdentityPicker: true,
|
||||||
|
MobileGamepad: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('This app opens in a new tab')
|
||||||
|
expect(wrapper.text()).toContain('Open in new tab')
|
||||||
|
})
|
||||||
|
})
|
||||||
70
neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts
Normal file
70
neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import Cloud from '../Cloud.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({ packages: {} }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
federationListNodes: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePeer() {
|
||||||
|
return {
|
||||||
|
did: 'did:key:peer',
|
||||||
|
pubkey: 'peer',
|
||||||
|
onion: 'peer.onion',
|
||||||
|
name: 'Peer Alpha',
|
||||||
|
trust_level: 'trusted',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Cloud peer list', () => {
|
||||||
|
it('keeps peer nodes visible while refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
|
||||||
|
|
||||||
|
const wrapper = mount(Cloud)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Peer Alpha')
|
||||||
|
expect(wrapper.text()).not.toContain('No peers yet')
|
||||||
|
|
||||||
|
const pending = deferred<{ nodes: [] }>()
|
||||||
|
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(pending.promise)
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadPeers: () => Promise<void> }).loadPeers()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Peer Alpha')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing peer nodes...')
|
||||||
|
expect(wrapper.text()).not.toContain('No peers yet')
|
||||||
|
|
||||||
|
pending.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Peer Alpha')
|
||||||
|
expect(wrapper.text()).toContain('offline')
|
||||||
|
expect(wrapper.text()).not.toContain('Refreshing peer nodes...')
|
||||||
|
expect(wrapper.text()).not.toContain('No peers yet')
|
||||||
|
})
|
||||||
|
})
|
||||||
76
neode-ui/src/views/__tests__/CredentialsRefresh.test.ts
Normal file
76
neode-ui/src/views/__tests__/CredentialsRefresh.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import Credentials from '../Credentials.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
call: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCredential(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: ['VerifiableCredential', 'NodeOperator'],
|
||||||
|
issuer: 'did:key:issuer',
|
||||||
|
credentialSubject: { id: 'did:key:subject' },
|
||||||
|
issuanceDate: '2026-06-10T10:00:00Z',
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Credentials', () => {
|
||||||
|
it('keeps credentials visible while refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'identity.list') return Promise.resolve({ identities: [] })
|
||||||
|
if (request.method === 'identity.list-credentials') {
|
||||||
|
return Promise.resolve({ credentials: [makeCredential('cred-one')] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(Credentials, {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$router: { push: vi.fn() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('NodeOperator')
|
||||||
|
expect(wrapper.text()).toContain('cred-one')
|
||||||
|
|
||||||
|
const pending = deferred<{ credentials: [] }>()
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'identity.list-credentials') return pending.promise
|
||||||
|
return Promise.resolve({ identities: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('NodeOperator')
|
||||||
|
expect(wrapper.text()).toContain('cred-one')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing credentials...')
|
||||||
|
|
||||||
|
pending.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('NodeOperator')
|
||||||
|
expect(wrapper.text()).toContain('cred-one')
|
||||||
|
expect(wrapper.text()).not.toContain('Refreshing credentials...')
|
||||||
|
})
|
||||||
|
})
|
||||||
46
neode-ui/src/views/__tests__/OnboardingOptions.test.ts
Normal file
46
neode-ui/src/views/__tests__/OnboardingOptions.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import OnboardingOptions from '../OnboardingOptions.vue'
|
||||||
|
|
||||||
|
const push = vi.fn(() => Promise.resolve())
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useNavSounds', () => ({
|
||||||
|
playNavSound: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('OnboardingOptions', () => {
|
||||||
|
it('shows only usable setup paths', () => {
|
||||||
|
const wrapper = mount(OnboardingOptions)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Fresh Start')
|
||||||
|
expect(wrapper.text()).toContain('Restore from Seed')
|
||||||
|
expect(wrapper.text()).not.toContain('Connect Existing')
|
||||||
|
expect(wrapper.text()).not.toContain('Coming Soon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('continues to fresh seed generation by default', async () => {
|
||||||
|
push.mockClear()
|
||||||
|
const wrapper = mount(OnboardingOptions)
|
||||||
|
|
||||||
|
await wrapper.get('button.path-action-button').trigger('click')
|
||||||
|
|
||||||
|
expect(push).toHaveBeenCalledWith('/onboarding/seed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes restore choice to seed restore', async () => {
|
||||||
|
push.mockClear()
|
||||||
|
const wrapper = mount(OnboardingOptions)
|
||||||
|
|
||||||
|
const restoreButton = wrapper.findAll('button').find((button) => button.text().includes('Restore from Seed'))
|
||||||
|
expect(restoreButton).toBeDefined()
|
||||||
|
|
||||||
|
await restoreButton!.trigger('click')
|
||||||
|
await wrapper.get('button.path-action-button').trigger('click')
|
||||||
|
|
||||||
|
expect(push).toHaveBeenCalledWith('/onboarding/seed-restore')
|
||||||
|
})
|
||||||
|
})
|
||||||
85
neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts
Normal file
85
neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import PeerFiles from '../PeerFiles.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
call: vi.fn(),
|
||||||
|
federationListNodes: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useAudioPlayer', () => ({
|
||||||
|
useAudioPlayer: () => ({ play: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCatalogItem() {
|
||||||
|
return {
|
||||||
|
id: 'file-1',
|
||||||
|
filename: 'notes.txt',
|
||||||
|
mime_type: 'text/plain',
|
||||||
|
size_bytes: 128,
|
||||||
|
description: '',
|
||||||
|
access: 'free',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PeerFiles', () => {
|
||||||
|
it('keeps peer catalog items visible while refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
|
||||||
|
nodes: [{
|
||||||
|
did: 'did:key:peer',
|
||||||
|
pubkey: 'peer',
|
||||||
|
onion: 'peer.onion',
|
||||||
|
name: 'Peer',
|
||||||
|
trust_level: 'trusted',
|
||||||
|
}],
|
||||||
|
} as never)
|
||||||
|
vi.mocked(rpcClient.call).mockResolvedValueOnce({ items: [makeCatalogItem()] })
|
||||||
|
|
||||||
|
const wrapper = mount(PeerFiles, {
|
||||||
|
props: { peerId: 'peer.onion' },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Teleport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('notes.txt')
|
||||||
|
|
||||||
|
const pending = deferred<{ items: [] }>()
|
||||||
|
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadCatalog: () => Promise<void> }).loadCatalog()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('notes.txt')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing peer files...')
|
||||||
|
expect(wrapper.text()).not.toContain('Connecting via Tor')
|
||||||
|
|
||||||
|
pending.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('notes.txt')
|
||||||
|
expect(wrapper.text()).toContain('offline')
|
||||||
|
expect(wrapper.text()).not.toContain('Refreshing peer files...')
|
||||||
|
})
|
||||||
|
})
|
||||||
216
neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts
Normal file
216
neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import Server from '../Server.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({ packages: {} }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
call: vi.fn(),
|
||||||
|
vpnStatus: vi.fn(),
|
||||||
|
dnsStatus: vi.fn(),
|
||||||
|
diskStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||||
|
return mount(Server, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
QuickActionsCard: true,
|
||||||
|
TorServicesCard: options.renderTorServices ? false : true,
|
||||||
|
ServerModals: true,
|
||||||
|
FipsNetworkCard: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Server network refresh states', () => {
|
||||||
|
it('keeps network overview visible while refresh is pending', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'network.diagnostics') {
|
||||||
|
return Promise.resolve({ tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' })
|
||||||
|
}
|
||||||
|
if (request.method === 'router.list-forwards') {
|
||||||
|
return Promise.resolve({ forwards: [{}, {}] })
|
||||||
|
}
|
||||||
|
if (request.method === 'network.list-interfaces') {
|
||||||
|
return Promise.resolve({ interfaces: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'tor.list-services') {
|
||||||
|
return Promise.resolve({ services: [], tor_running: false })
|
||||||
|
}
|
||||||
|
if (request.method === 'vpn.list-peers') {
|
||||||
|
return Promise.resolve({ peers: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'fips.status') {
|
||||||
|
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24' } as never)
|
||||||
|
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'cloudflare', resolv_conf_servers: ['1.1.1.1'], doh_enabled: true } as never)
|
||||||
|
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||||
|
|
||||||
|
const wrapper = mountServer()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Lab WiFi')
|
||||||
|
expect(wrapper.text()).toContain('2 rules')
|
||||||
|
|
||||||
|
const pendingDiagnostics = deferred<{ tor_connected: boolean; wifi_count: number; wifi_ssid: string }>()
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'network.diagnostics') return pendingDiagnostics.promise
|
||||||
|
if (request.method === 'router.list-forwards') return Promise.reject(new Error('offline'))
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||||
|
vi.mocked(rpcClient.dnsStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Lab WiFi')
|
||||||
|
expect(wrapper.text()).toContain('2 rules')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing network...')
|
||||||
|
|
||||||
|
pendingDiagnostics.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Lab WiFi')
|
||||||
|
expect(wrapper.text()).toContain('2 rules')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps network interfaces visible while refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'network.list-interfaces') {
|
||||||
|
return Promise.resolve({
|
||||||
|
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (request.method === 'network.diagnostics') {
|
||||||
|
return Promise.resolve({ tor_connected: false })
|
||||||
|
}
|
||||||
|
if (request.method === 'router.list-forwards') {
|
||||||
|
return Promise.resolve({ forwards: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'tor.list-services') {
|
||||||
|
return Promise.resolve({ services: [], tor_running: false })
|
||||||
|
}
|
||||||
|
if (request.method === 'vpn.list-peers') {
|
||||||
|
return Promise.resolve({ peers: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'fips.status') {
|
||||||
|
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||||
|
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||||
|
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||||
|
|
||||||
|
const wrapper = mountServer()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('eth0')
|
||||||
|
expect(wrapper.text()).toContain('192.168.1.10')
|
||||||
|
|
||||||
|
const pendingInterfaces = deferred<{ interfaces: [] }>()
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'network.list-interfaces') return pendingInterfaces.promise
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadInterfaces: () => Promise<void> }).loadInterfaces()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('eth0')
|
||||||
|
expect(wrapper.text()).toContain('192.168.1.10')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing interfaces...')
|
||||||
|
|
||||||
|
pendingInterfaces.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('eth0')
|
||||||
|
expect(wrapper.text()).toContain('192.168.1.10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Tor services visible while refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'tor.list-services') {
|
||||||
|
return Promise.resolve({
|
||||||
|
services: [{
|
||||||
|
name: 'filebrowser',
|
||||||
|
local_port: 8080,
|
||||||
|
onion_address: 'filebrowser123456789.onion',
|
||||||
|
enabled: true,
|
||||||
|
unauthenticated: false,
|
||||||
|
protocol: false,
|
||||||
|
}],
|
||||||
|
tor_running: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (request.method === 'network.diagnostics') {
|
||||||
|
return Promise.resolve({ tor_connected: true })
|
||||||
|
}
|
||||||
|
if (request.method === 'router.list-forwards') {
|
||||||
|
return Promise.resolve({ forwards: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'network.list-interfaces') {
|
||||||
|
return Promise.resolve({ interfaces: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'vpn.list-peers') {
|
||||||
|
return Promise.resolve({ peers: [] })
|
||||||
|
}
|
||||||
|
if (request.method === 'fips.status') {
|
||||||
|
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||||
|
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||||
|
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||||
|
|
||||||
|
const wrapper = mountServer({ renderTorServices: true })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('filebrowser')
|
||||||
|
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||||
|
|
||||||
|
const pendingTor = deferred<{ services: []; tor_running: boolean }>()
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||||
|
if (request.method === 'tor.list-services') return pendingTor.promise
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadTorServices: () => Promise<void> }).loadTorServices()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('filebrowser')
|
||||||
|
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing Tor services...')
|
||||||
|
|
||||||
|
pendingTor.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('filebrowser')
|
||||||
|
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||||
|
})
|
||||||
|
})
|
||||||
16
neode-ui/src/views/__tests__/cloudPath.test.ts
Normal file
16
neode-ui/src/views/__tests__/cloudPath.test.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeCloudPath, parentCloudPath } from '../cloudPath'
|
||||||
|
|
||||||
|
describe('cloudPath helpers', () => {
|
||||||
|
it('normalizes query paths', () => {
|
||||||
|
expect(normalizeCloudPath('Photos/Trips')).toBe('/Photos/Trips')
|
||||||
|
expect(normalizeCloudPath('/Photos//Trips')).toBe('/Photos/Trips')
|
||||||
|
expect(normalizeCloudPath('', '/Photos')).toBe('/Photos')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('walks to the parent folder without leaving root', () => {
|
||||||
|
expect(parentCloudPath('/Photos/Trips/Day 1')).toBe('/Photos/Trips')
|
||||||
|
expect(parentCloudPath('/Photos')).toBe('/')
|
||||||
|
expect(parentCloudPath('/')).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,31 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Screenshots Gallery -->
|
<!-- Screenshots Gallery -->
|
||||||
<div class="glass-card p-6">
|
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div
|
<img
|
||||||
v-for="i in 4"
|
v-for="screenshot in screenshots"
|
||||||
:key="i"
|
:key="screenshot.src"
|
||||||
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
|
:src="screenshot.src"
|
||||||
>
|
:alt="screenshot.alt"
|
||||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
loading="lazy"
|
||||||
</svg>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bitcoin Sync Warning (for dependent apps) -->
|
<!-- Bitcoin Sync Warning (for dependent apps) -->
|
||||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-5 border border-orange-500/30">
|
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-6 border border-orange-500/30">
|
||||||
<div class="flex items-center gap-3 mb-3">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
<svg class="w-6 h-6 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-orange-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-orange-300 font-semibold text-sm">Bitcoin is syncing</p>
|
<p class="text-orange-300 font-semibold text-xl">Bitcoin is syncing</p>
|
||||||
<p class="text-white/60 text-xs mt-0.5">
|
<p class="text-white/70 mt-2 leading-relaxed">
|
||||||
Some features may be unavailable until Bitcoin finishes syncing.
|
Some features may be unavailable until Bitcoin finishes syncing.
|
||||||
Wallet connections and block data require a fully synced node.
|
Wallet connections and block data require a fully synced node.
|
||||||
</p>
|
</p>
|
||||||
@ -37,7 +35,7 @@
|
|||||||
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
|
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-white/40 mt-1.5">
|
<p class="text-sm text-white/55 mt-2">
|
||||||
{{ bitcoinSyncPercent.toFixed(1) }}% synced — Block {{ bitcoinBlockHeight.toLocaleString() }}
|
{{ bitcoinSyncPercent.toFixed(1) }}% synced — Block {{ bitcoinBlockHeight.toLocaleString() }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -70,11 +68,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { AppScreenshot } from '@/types/api'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
pkg: Record<string, any>
|
pkg: Record<string, any>
|
||||||
features: string[]
|
features: string[]
|
||||||
needsBitcoinSync: boolean
|
needsBitcoinSync: boolean
|
||||||
@ -82,4 +82,28 @@ defineProps<{
|
|||||||
bitcoinSyncPercent: number
|
bitcoinSyncPercent: number
|
||||||
bitcoinBlockHeight: number
|
bitcoinBlockHeight: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const screenshots = computed(() => {
|
||||||
|
const manifestScreenshots = props.pkg.manifest?.screenshots
|
||||||
|
const staticScreenshots = props.pkg['static-files']?.screenshots
|
||||||
|
return normalizeScreenshots(Array.isArray(staticScreenshots) ? staticScreenshots : manifestScreenshots)
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeScreenshots(items: AppScreenshot[] | undefined) {
|
||||||
|
if (!Array.isArray(items)) return []
|
||||||
|
return items
|
||||||
|
.map((item, index) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const src = item.trim()
|
||||||
|
return src ? { src, alt: `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}` } : null
|
||||||
|
}
|
||||||
|
const src = item.src?.trim()
|
||||||
|
if (!src) return null
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
alt: item.alt?.trim() || `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,225 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass-card p-6 mb-6">
|
<div class="glass-card p-6 mb-6">
|
||||||
<!-- Desktop: Single Row Layout -->
|
<div class="flex items-start md:items-center gap-4 md:gap-6">
|
||||||
<div class="hidden md:flex items-center gap-6">
|
|
||||||
<img
|
<img
|
||||||
:src="icon"
|
:src="icon"
|
||||||
:alt="pkg.manifest.title"
|
:alt="pkg.manifest.title"
|
||||||
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
|
class="app-detail-icon archy-app-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h1 class="text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
<h1 class="text-xl md:text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||||
<p class="text-white/70 text-sm mb-2">{{ pkg.manifest.description.short }}</p>
|
<p class="text-white/70 text-xs md:text-sm mb-2 line-clamp-2 md:line-clamp-none">{{ pkg.manifest.description.short }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
|
class="inline-flex items-center px-2 py-0.5 md:px-2.5 md:py-1 rounded-lg text-xs font-medium"
|
||||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||||
>
|
>
|
||||||
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<div class="app-detail-actions-top items-center gap-2 flex-shrink-0">
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<!-- Update available -->
|
|
||||||
<button
|
|
||||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
|
||||||
@click="$emit('update')"
|
|
||||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Update to v{{ pkg['available-update'] }}
|
|
||||||
</button>
|
|
||||||
<!-- Updating in progress -->
|
|
||||||
<span
|
<span
|
||||||
v-if="pkg.state === 'updating'"
|
v-if="pkg.state === 'updating'"
|
||||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center gap-2"
|
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
Updating...
|
Updating...
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="packageKey === 'lnd'"
|
v-for="action in actionItems"
|
||||||
@click="$emit('channels')"
|
:key="`top-${action.key}`"
|
||||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
|
type="button"
|
||||||
|
:disabled="controlsDisabled"
|
||||||
|
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed', action.class]"
|
||||||
|
@click="emitAction(action.emit)"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{{ action.label }}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
{{ t('appDetails.channels') }}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="canLaunch"
|
|
||||||
@click="$emit('launch')"
|
|
||||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
{{ t('common.launch') }}
|
|
||||||
</button>
|
|
||||||
<template v-if="!isWebOnly">
|
|
||||||
<button
|
|
||||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
|
||||||
@click="$emit('start')"
|
|
||||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
|
||||||
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
|
|
||||||
>
|
|
||||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
</svg>
|
|
||||||
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="$emit('restart')"
|
|
||||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{{ t('common.restart') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="pkg.state === 'running'"
|
|
||||||
@click="$emit('stop')"
|
|
||||||
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
{{ t('common.stop') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="$emit('uninstall')"
|
|
||||||
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
{{ t('common.uninstall') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: Two Column Grid Layout -->
|
<div class="app-detail-actions-bottom mt-5 grid grid-cols-2 gap-3">
|
||||||
<div class="md:hidden">
|
<template v-if="pkg.state === 'updating'">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
|
||||||
<img
|
|
||||||
:src="icon"
|
|
||||||
:alt="pkg.manifest.title"
|
|
||||||
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h1 class="text-xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
|
||||||
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ pkg.manifest.description.short }}</p>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
|
||||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
|
||||||
>
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
|
||||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
|
||||||
</span>
|
|
||||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="!isWebOnly"
|
|
||||||
@click="$emit('uninstall')"
|
|
||||||
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
|
|
||||||
:title="t('common.uninstall')"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" 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>
|
|
||||||
|
|
||||||
<!-- Action Buttons (Auto Grid) -->
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<!-- Update available (mobile) -->
|
|
||||||
<button
|
|
||||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
|
||||||
@click="$emit('update')"
|
|
||||||
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Update to v{{ pkg['available-update'] }}
|
|
||||||
</button>
|
|
||||||
<!-- Updating in progress (mobile) -->
|
|
||||||
<span
|
<span
|
||||||
v-if="pkg.state === 'updating'"
|
class="col-span-2 mobile-card-action bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||||
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
Updating...
|
Updating...
|
||||||
</span>
|
</span>
|
||||||
<button
|
</template>
|
||||||
v-if="canLaunch"
|
<button
|
||||||
@click="$emit('launch')"
|
v-for="action in actionItems"
|
||||||
:class="isWebOnly ? 'col-span-2' : ''"
|
:key="`bottom-${action.key}`"
|
||||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
type="button"
|
||||||
>
|
:disabled="controlsDisabled"
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
:class="[
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
</svg>
|
action.class,
|
||||||
{{ t('common.launch') }}
|
actionItems.length === 1 || action.full ? 'col-span-2' : '',
|
||||||
</button>
|
]"
|
||||||
<template v-if="!isWebOnly">
|
@click="emitAction(action.emit)"
|
||||||
<button
|
>
|
||||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
{{ action.label }}
|
||||||
@click="$emit('start')"
|
</button>
|
||||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
|
||||||
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
|
|
||||||
>
|
|
||||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
</svg>
|
|
||||||
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="pkg.state === 'running'"
|
|
||||||
@click="$emit('stop')"
|
|
||||||
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
{{ t('common.stop') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="$emit('restart')"
|
|
||||||
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
|
|
||||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{{ t('common.restart') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -229,6 +74,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { PackageDataEntry } from '@/types/api'
|
import type { PackageDataEntry } from '@/types/api'
|
||||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||||
|
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -239,11 +85,13 @@ const props = defineProps<{
|
|||||||
packageKey: string
|
packageKey: string
|
||||||
canLaunch: boolean
|
canLaunch: boolean
|
||||||
isWebOnly: boolean
|
isWebOnly: boolean
|
||||||
|
pendingAction: 'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||||
|
const controlsDisabled = computed(() => props.pendingAction !== null || props.pkg.state === 'updating')
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
launch: []
|
launch: []
|
||||||
start: []
|
start: []
|
||||||
stop: []
|
stop: []
|
||||||
@ -253,10 +101,110 @@ defineEmits<{
|
|||||||
channels: []
|
channels: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
type ActionEmit = 'launch' | 'start' | 'stop' | 'restart' | 'uninstall' | 'update' | 'channels'
|
||||||
|
|
||||||
|
const actionItems = computed(() => {
|
||||||
|
const actions: Array<{ key: string; emit: ActionEmit; label: string; class: string; full?: boolean }> = []
|
||||||
|
|
||||||
|
if (props.pkg['available-update'] && props.pkg.state !== 'updating') {
|
||||||
|
actions.push({
|
||||||
|
key: 'update',
|
||||||
|
emit: 'update',
|
||||||
|
label: props.pendingAction === 'update' ? 'Updating...' : `Update to v${props.pkg['available-update']}`,
|
||||||
|
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
|
||||||
|
full: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.packageKey === 'lnd') {
|
||||||
|
actions.push({
|
||||||
|
key: 'channels',
|
||||||
|
emit: 'channels',
|
||||||
|
label: t('appDetails.channels'),
|
||||||
|
class: 'glass-button',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.canLaunch) {
|
||||||
|
actions.push({
|
||||||
|
key: 'launch',
|
||||||
|
emit: 'launch',
|
||||||
|
label: t('common.launch'),
|
||||||
|
class: 'glass-button font-semibold',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.isWebOnly) {
|
||||||
|
if (props.pkg.state === 'stopped' || props.pkg.state === 'exited') {
|
||||||
|
actions.push({
|
||||||
|
key: 'start',
|
||||||
|
emit: 'start',
|
||||||
|
label: props.pendingAction === 'start' ? 'Starting...' : props.pkg.state === 'exited' ? 'Restart' : t('common.start'),
|
||||||
|
class: props.pkg.state === 'exited' ? 'glass-button glass-button-danger' : 'glass-button glass-button-success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.pkg.state === 'running') {
|
||||||
|
actions.push({
|
||||||
|
key: 'stop',
|
||||||
|
emit: 'stop',
|
||||||
|
label: props.pendingAction === 'stop' ? 'Stopping...' : t('common.stop'),
|
||||||
|
class: 'glass-button text-yellow-200 border-yellow-500/30 hover:bg-yellow-500/10',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
key: 'restart',
|
||||||
|
emit: 'restart',
|
||||||
|
label: props.pendingAction === 'restart' ? 'Restarting...' : t('common.restart'),
|
||||||
|
class: 'glass-button',
|
||||||
|
})
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
key: 'uninstall',
|
||||||
|
emit: 'uninstall',
|
||||||
|
label: props.pendingAction === 'uninstall' ? 'Uninstalling...' : t('common.uninstall'),
|
||||||
|
class: 'glass-button text-red-300 border-red-500/30 hover:bg-red-500/10',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
})
|
||||||
|
|
||||||
|
function emitAction(action: ActionEmit) {
|
||||||
|
emit(action)
|
||||||
|
}
|
||||||
|
|
||||||
function handleImageError(e: Event) {
|
function handleImageError(e: Event) {
|
||||||
const target = e.target as HTMLImageElement
|
const target = e.target as HTMLImageElement
|
||||||
if (!target.src.includes('data:image') && !target.src.includes('logo-archipelago')) {
|
if (!target.src.includes(DEFAULT_APP_ICON)) {
|
||||||
target.src = '/assets/img/logo-archipelago.svg'
|
target.src = DEFAULT_APP_ICON
|
||||||
|
target.dataset.defaultIcon = '1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-detail-actions-top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-detail-actions-bottom {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-detail-action-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.app-detail-actions-top {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-detail-actions-bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -86,10 +86,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="credentials?.credentials?.length" class="glass-card p-6">
|
<!-- Setup Instructions -->
|
||||||
|
<div v-if="setupInstructions" class="glass-card p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white mb-3">{{ t('appDetails.setupInstructions') }}</h3>
|
||||||
|
<p class="text-sm text-white/70 leading-relaxed whitespace-pre-line">
|
||||||
|
{{ setupInstructions }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="credentialsLoading || credentials?.credentials?.length" class="glass-card p-6">
|
||||||
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
|
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
|
||||||
<p v-if="credentials.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
<p v-if="credentials?.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||||
<div class="space-y-3">
|
<div v-if="credentialsLoading" class="space-y-3" aria-live="polite">
|
||||||
|
<div class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||||
|
<div class="h-3 w-28 rounded bg-white/10 mb-3"></div>
|
||||||
|
<div class="h-4 w-full rounded bg-white/10"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-white/50">Loading credentials...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||||
<div class="flex items-center justify-between gap-3 mb-1">
|
<div class="flex items-center justify-between gap-3 mb-1">
|
||||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||||
@ -128,38 +143,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Links Card -->
|
<!-- Links Card -->
|
||||||
<div class="glass-card p-6">
|
<div v-if="links.length > 0" class="glass-card p-6">
|
||||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<a
|
<a
|
||||||
v-if="pkg.manifest.website"
|
v-for="link in links"
|
||||||
:href="pkg.manifest.website"
|
:key="link.kind"
|
||||||
|
:href="link.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
v-if="link.kind === 'website'"
|
||||||
|
class="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('appDetails.website') }}
|
<svg
|
||||||
</a>
|
v-else-if="link.kind === 'source'"
|
||||||
<a
|
class="w-5 h-5"
|
||||||
href="#"
|
fill="currentColor"
|
||||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('appDetails.sourceCode') }}
|
<svg
|
||||||
</a>
|
v-else
|
||||||
<a
|
class="w-5 h-5"
|
||||||
href="#"
|
fill="none"
|
||||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('appDetails.documentation') }}
|
{{ link.label }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -167,7 +188,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { AppCredentialsResponse } from '@/types/api'
|
import type { AppCredentialsResponse } from '@/types/api'
|
||||||
|
|
||||||
@ -191,7 +212,7 @@ async function copyCredential(label: string, value: string) {
|
|||||||
}, 1800)
|
}, 1800)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
pkg: Record<string, any>
|
pkg: Record<string, any>
|
||||||
packageKey: string
|
packageKey: string
|
||||||
isWebOnly: boolean
|
isWebOnly: boolean
|
||||||
@ -201,5 +222,40 @@ defineProps<{
|
|||||||
torUrl: string
|
torUrl: string
|
||||||
showTorAddress: boolean
|
showTorAddress: boolean
|
||||||
credentials: AppCredentialsResponse | null
|
credentials: AppCredentialsResponse | null
|
||||||
|
credentialsLoading: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
type LinkKind = 'website' | 'source' | 'documentation'
|
||||||
|
|
||||||
|
interface SidebarLink {
|
||||||
|
kind: LinkKind
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHttpUrl(value: unknown): string {
|
||||||
|
const url = typeof value === 'string' ? value.trim() : ''
|
||||||
|
if (!url || url === '#') return ''
|
||||||
|
if (/^https?:\/\//i.test(url)) return url
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = computed<SidebarLink[]>(() => {
|
||||||
|
const manifest = props.pkg.manifest || {}
|
||||||
|
const website = normalizeHttpUrl(manifest.website || manifest['marketing-site'])
|
||||||
|
const source = normalizeHttpUrl(manifest['upstream-repo'] || manifest['wrapper-repo'])
|
||||||
|
const documentation = normalizeHttpUrl(manifest['support-site'])
|
||||||
|
|
||||||
|
return [
|
||||||
|
website ? { kind: 'website' as const, label: t('appDetails.website'), url: website } : null,
|
||||||
|
source ? { kind: 'source' as const, label: t('appDetails.sourceCode'), url: source } : null,
|
||||||
|
documentation ? { kind: 'documentation' as const, label: t('appDetails.documentation'), url: documentation } : null,
|
||||||
|
].filter((link): link is SidebarLink => link !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const setupInstructions = computed(() => {
|
||||||
|
const raw = props.pkg['static-files']?.instructions
|
||||||
|
const instructions = typeof raw === 'string' ? raw.trim() : ''
|
||||||
|
return instructions ? instructions : ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import AppContentSection from '../AppContentSection.vue'
|
||||||
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
appDetails: {
|
||||||
|
about: 'About {name}',
|
||||||
|
features: 'Features',
|
||||||
|
screenshots: 'Screenshots',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||||
|
return {
|
||||||
|
state: PackageState.Running,
|
||||||
|
health: 'healthy',
|
||||||
|
manifest: {
|
||||||
|
id: 'example',
|
||||||
|
title: 'Example',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: {
|
||||||
|
short: 'Example app',
|
||||||
|
long: 'Example app details',
|
||||||
|
},
|
||||||
|
'release-notes': '',
|
||||||
|
license: '',
|
||||||
|
'wrapper-repo': '',
|
||||||
|
'upstream-repo': '',
|
||||||
|
'support-site': '',
|
||||||
|
'marketing-site': '',
|
||||||
|
'donation-url': null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountContent(pkg: PackageDataEntry) {
|
||||||
|
return mount(AppContentSection, {
|
||||||
|
props: {
|
||||||
|
pkg,
|
||||||
|
features: [],
|
||||||
|
needsBitcoinSync: false,
|
||||||
|
bitcoinSynced: true,
|
||||||
|
bitcoinSyncPercent: 100,
|
||||||
|
bitcoinBlockHeight: 100,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppContentSection', () => {
|
||||||
|
it('does not show screenshot placeholders when no screenshots exist', () => {
|
||||||
|
const wrapper = mountContent(packageData())
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Screenshots')
|
||||||
|
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders real screenshot metadata when provided', () => {
|
||||||
|
const wrapper = mountContent(packageData({
|
||||||
|
manifest: {
|
||||||
|
...packageData().manifest,
|
||||||
|
screenshots: [
|
||||||
|
'/assets/screenshots/example-dashboard.png',
|
||||||
|
{ src: '/assets/screenshots/example-settings.png', alt: 'Settings screen' },
|
||||||
|
' ',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const images = wrapper.findAll('img')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Screenshots')
|
||||||
|
expect(images).toHaveLength(2)
|
||||||
|
expect(images[0].attributes('src')).toBe('/assets/screenshots/example-dashboard.png')
|
||||||
|
expect(images[0].attributes('alt')).toBe('Example screenshot 1')
|
||||||
|
expect(images[1].attributes('src')).toBe('/assets/screenshots/example-settings.png')
|
||||||
|
expect(images[1].attributes('alt')).toBe('Settings screen')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import AppHeroSection from '../AppHeroSection.vue'
|
||||||
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
launch: 'Launch',
|
||||||
|
restart: 'Restart',
|
||||||
|
start: 'Start',
|
||||||
|
stop: 'Stop',
|
||||||
|
uninstall: 'Uninstall',
|
||||||
|
},
|
||||||
|
appDetails: {
|
||||||
|
channels: 'Channels',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||||
|
return {
|
||||||
|
state: PackageState.Running,
|
||||||
|
health: 'healthy',
|
||||||
|
manifest: {
|
||||||
|
id: 'example',
|
||||||
|
title: 'Example',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: {
|
||||||
|
short: 'Example app',
|
||||||
|
long: 'Example app',
|
||||||
|
},
|
||||||
|
'release-notes': '',
|
||||||
|
license: '',
|
||||||
|
'wrapper-repo': '',
|
||||||
|
'upstream-repo': '',
|
||||||
|
'support-site': '',
|
||||||
|
'marketing-site': '',
|
||||||
|
'donation-url': null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountHero(props: Partial<InstanceType<typeof AppHeroSection>['$props']> = {}) {
|
||||||
|
return mount(AppHeroSection, {
|
||||||
|
props: {
|
||||||
|
pkg: packageData(),
|
||||||
|
appId: 'example',
|
||||||
|
packageKey: 'example',
|
||||||
|
canLaunch: true,
|
||||||
|
isWebOnly: false,
|
||||||
|
pendingAction: null,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppHeroSection', () => {
|
||||||
|
it('disables app controls while a container action is running', () => {
|
||||||
|
const wrapper = mountHero({ pendingAction: 'restart' })
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Restarting...')
|
||||||
|
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels update progress and disables update controls', () => {
|
||||||
|
const wrapper = mountHero({
|
||||||
|
pendingAction: 'update',
|
||||||
|
pkg: packageData({ 'available-update': '1.1.0' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Updating...')
|
||||||
|
const updateButtons = wrapper.findAll('button').filter(button => button.text().includes('Updating...'))
|
||||||
|
expect(updateButtons.length).toBeGreaterThan(0)
|
||||||
|
expect(updateButtons.every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
135
neode-ui/src/views/appDetails/__tests__/AppSidebar.test.ts
Normal file
135
neode-ui/src/views/appDetails/__tests__/AppSidebar.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import AppSidebar from '../AppSidebar.vue'
|
||||||
|
import { PackageState } from '@/types/api'
|
||||||
|
|
||||||
|
function mountSidebar(manifestOverrides: Record<string, unknown>, propOverrides: Record<string, unknown> = {}) {
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
appDetails: {
|
||||||
|
access: 'Access',
|
||||||
|
documentation: 'Documentation',
|
||||||
|
gateway: 'Gateway',
|
||||||
|
information: 'Information',
|
||||||
|
lan: 'LAN',
|
||||||
|
links: 'Links',
|
||||||
|
ram: 'RAM',
|
||||||
|
ramDesc: 'Memory required',
|
||||||
|
requirements: 'Requirements',
|
||||||
|
setupInstructions: 'Setup Instructions',
|
||||||
|
requiresTor: 'Requires Tor',
|
||||||
|
services: 'Services',
|
||||||
|
sourceCode: 'Source Code',
|
||||||
|
storage: 'Storage',
|
||||||
|
storageDesc: 'Storage required',
|
||||||
|
tor: 'Tor',
|
||||||
|
website: 'Website',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
category: 'Category',
|
||||||
|
developer: 'Developer',
|
||||||
|
license: 'License',
|
||||||
|
status: 'Status',
|
||||||
|
version: 'Version',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return mount(AppSidebar, {
|
||||||
|
props: {
|
||||||
|
pkg: {
|
||||||
|
state: PackageState.Running,
|
||||||
|
manifest: {
|
||||||
|
id: 'example',
|
||||||
|
title: 'Example',
|
||||||
|
version: '1.0.0',
|
||||||
|
license: '',
|
||||||
|
...manifestOverrides,
|
||||||
|
},
|
||||||
|
'static-files': { license: '', instructions: 'Follow step 1.\nThen step 2.', icon: '' },
|
||||||
|
},
|
||||||
|
packageKey: 'example',
|
||||||
|
isWebOnly: false,
|
||||||
|
gatewayState: 'stopped',
|
||||||
|
interfaceAddresses: null,
|
||||||
|
lanUrl: '',
|
||||||
|
torUrl: '',
|
||||||
|
showTorAddress: false,
|
||||||
|
credentials: null,
|
||||||
|
credentialsLoading: false,
|
||||||
|
...propOverrides,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppSidebar', () => {
|
||||||
|
it('renders manifest links with real URLs', () => {
|
||||||
|
const wrapper = mountSidebar({
|
||||||
|
website: 'https://example.com',
|
||||||
|
'upstream-repo': 'https://github.com/example/app',
|
||||||
|
'support-site': 'https://docs.example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
const hrefs = wrapper.findAll('a').map(anchor => anchor.attributes('href'))
|
||||||
|
|
||||||
|
expect(hrefs).toEqual([
|
||||||
|
'https://example.com',
|
||||||
|
'https://github.com/example/app',
|
||||||
|
'https://docs.example.com',
|
||||||
|
])
|
||||||
|
expect(wrapper.text()).toContain('Website')
|
||||||
|
expect(wrapper.text()).toContain('Source Code')
|
||||||
|
expect(wrapper.text()).toContain('Documentation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render dead placeholder links', () => {
|
||||||
|
const wrapper = mountSidebar({
|
||||||
|
website: '#',
|
||||||
|
'upstream-repo': '',
|
||||||
|
'support-site': undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.findAll('a')).toHaveLength(0)
|
||||||
|
expect(wrapper.text()).not.toContain('Links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a credentials loading state before credentials arrive', () => {
|
||||||
|
const wrapper = mountSidebar({}, { credentialsLoading: true })
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Credentials')
|
||||||
|
expect(wrapper.text()).toContain('Loading credentials...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders setup instructions when provided', () => {
|
||||||
|
const wrapper = mountSidebar({})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Setup Instructions')
|
||||||
|
expect(wrapper.text()).toContain('Follow step 1.')
|
||||||
|
expect(wrapper.text()).toContain('Then step 2.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides setup instructions when empty', () => {
|
||||||
|
const wrapper = mountSidebar({}, {
|
||||||
|
pkg: {
|
||||||
|
state: PackageState.Running,
|
||||||
|
manifest: {
|
||||||
|
id: 'example',
|
||||||
|
title: 'Example',
|
||||||
|
version: '1.0.0',
|
||||||
|
license: '',
|
||||||
|
},
|
||||||
|
'static-files': { license: '', instructions: ' ', icon: '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Setup Instructions')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -37,9 +37,10 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">{{ mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable' }}</h3>
|
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||||
<p class="text-white/50 text-sm mb-6">
|
<p class="text-white/50 text-sm mb-6">
|
||||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||||
|
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
@ -88,6 +89,8 @@ const props = defineProps<{
|
|||||||
mustOpenNewTab: boolean
|
mustOpenNewTab: boolean
|
||||||
autoRetryCount: number
|
autoRetryCount: number
|
||||||
refreshKey: number
|
refreshKey: number
|
||||||
|
blockedReason?: string
|
||||||
|
blockedTitle?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||||
|
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||||
|
|
||||||
describe('appSessionConfig', () => {
|
describe('appSessionConfig', () => {
|
||||||
it('keeps new-tab apps marked on every viewport', () => {
|
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||||
expect(NEW_TAB_APPS.has('grafana')).toBe(true)
|
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||||
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
|
expect(GENERATED_NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps frontend-only new-tab overrides for apps without generated metadata', () => {
|
||||||
|
expect(NEW_TAB_APPS.has('tailscale')).toBe(true)
|
||||||
|
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves direct app ports against the current browser host', () => {
|
it('resolves direct app ports against the current browser host', () => {
|
||||||
@ -17,7 +23,17 @@ describe('appSessionConfig', () => {
|
|||||||
|
|
||||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
||||||
expect(resolveAppUrl('saleor')).toBe('http://192.168.1.228:9011')
|
expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { hostname: '192.168.1.228' },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolveAppUrl('meshtastic')).toBe('http://192.168.1.228:4403')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps NetBird on the unified dashboard proxy port', () => {
|
it('keeps NetBird on the unified dashboard proxy port', () => {
|
||||||
@ -29,4 +45,14 @@ describe('appSessionConfig', () => {
|
|||||||
|
|
||||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
|
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { hostname: '192.168.1.228' },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
/** Static configuration maps for app session routing and display */
|
/** Static configuration maps for app session routing and display */
|
||||||
|
|
||||||
|
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||||
|
|
||||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||||
|
|
||||||
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||||
|
|
||||||
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||||
export const APP_PORTS: Record<string, number> = {
|
export const APP_PORTS: Record<string, number> = {
|
||||||
|
...GENERATED_APP_PORTS,
|
||||||
'bitcoin-knots': 8334,
|
'bitcoin-knots': 8334,
|
||||||
'bitcoin-core': 8334,
|
'bitcoin-core': 8334,
|
||||||
'bitcoin-ui': 8334,
|
'bitcoin-ui': 8334,
|
||||||
@ -14,7 +17,6 @@ export const APP_PORTS: Record<string, number> = {
|
|||||||
'archy-electrs-ui': 50002,
|
'archy-electrs-ui': 50002,
|
||||||
'mempool-electrs': 50002,
|
'mempool-electrs': 50002,
|
||||||
'btcpay-server': 23000,
|
'btcpay-server': 23000,
|
||||||
'saleor': 9011,
|
|
||||||
'lnd': 18083,
|
'lnd': 18083,
|
||||||
'archy-lnd-ui': 18083,
|
'archy-lnd-ui': 18083,
|
||||||
'mempool': 4080,
|
'mempool': 4080,
|
||||||
@ -71,8 +73,9 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const APP_TITLES: Record<string, string> = {
|
export const APP_TITLES: Record<string, string> = {
|
||||||
|
...GENERATED_APP_TITLES,
|
||||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||||
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
|
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||||
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
||||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||||
@ -82,17 +85,8 @@ export const APP_TITLES: Record<string, string> = {
|
|||||||
|
|
||||||
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
||||||
export const NEW_TAB_APPS = new Set([
|
export const NEW_TAB_APPS = new Set([
|
||||||
'btcpay-server',
|
...GENERATED_NEW_TAB_APPS,
|
||||||
'grafana',
|
|
||||||
'photoprism',
|
|
||||||
'homeassistant',
|
|
||||||
'vaultwarden',
|
|
||||||
'nextcloud',
|
|
||||||
'uptime-kuma',
|
|
||||||
'portainer',
|
|
||||||
'onlyoffice',
|
|
||||||
'nginx-proxy-manager',
|
'nginx-proxy-manager',
|
||||||
'gitea',
|
|
||||||
'tailscale',
|
'tailscale',
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -100,7 +94,7 @@ export const NEW_TAB_APPS = new Set([
|
|||||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||||
|
|
||||||
/** Resolve app URL using direct port mapping (source of truth) */
|
/** Resolve app URL using direct port mapping (source of truth) */
|
||||||
export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?: string): string {
|
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
||||||
// External HTTPS apps
|
// External HTTPS apps
|
||||||
const ext = EXTERNAL_URLS[id]
|
const ext = EXTERNAL_URLS[id]
|
||||||
if (ext) return ext
|
if (ext) return ext
|
||||||
@ -109,9 +103,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?:
|
|||||||
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
|
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
|
||||||
// shell when proxied under a path prefix on some nodes.
|
// shell when proxied under a path prefix on some nodes.
|
||||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||||
|
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||||
return 'http://' + window.location.hostname + ':8334'
|
return 'http://' + window.location.hostname + ':8334'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runtimeUrl && id !== 'netbird') {
|
||||||
|
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||||
|
if (routeQueryPath) base += routeQueryPath
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
// Local apps launch by host port.
|
// Local apps launch by host port.
|
||||||
const port = APP_PORTS[id]
|
const port = APP_PORTS[id]
|
||||||
if (!port) return ''
|
if (!port) return ''
|
||||||
|
|||||||
17
neode-ui/src/views/appStoreCategories.ts
Normal file
17
neode-ui/src/views/appStoreCategories.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const APP_STORE_CATEGORIES = [
|
||||||
|
{ id: 'all', name: 'All' },
|
||||||
|
{ id: 'community', name: 'Community' },
|
||||||
|
{ id: 'nostr', name: 'Nostr' },
|
||||||
|
{ id: 'commerce', name: 'Commerce' },
|
||||||
|
{ id: 'money', name: 'Money' },
|
||||||
|
{ id: 'data', name: 'Data' },
|
||||||
|
{ id: 'home', name: 'Home' },
|
||||||
|
{ id: 'networking', name: 'Networking' },
|
||||||
|
{ id: 'l484', name: 'L484' },
|
||||||
|
{ id: 'other', name: 'Other' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const APP_STORE_SECTIONS = [
|
||||||
|
{ id: 'discover', name: 'Discover' },
|
||||||
|
...APP_STORE_CATEGORIES,
|
||||||
|
] as const
|
||||||
@ -31,7 +31,7 @@
|
|||||||
<img
|
<img
|
||||||
:src="icon"
|
:src="icon"
|
||||||
:alt="title"
|
:alt="title"
|
||||||
class="app-card-icon w-14 h-14 object-cover bg-white/10"
|
class="app-card-icon archy-app-icon w-14 h-14"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0 overflow-hidden">
|
<div class="flex-1 min-w-0 overflow-hidden">
|
||||||
@ -77,6 +77,9 @@
|
|||||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="blockedReason" class="mt-2 text-xs leading-snug text-yellow-200/80">
|
||||||
|
{{ blockedReason }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Quick Actions — icon buttons in uniform dark containers -->
|
<!-- Quick Actions — icon buttons in uniform dark containers -->
|
||||||
<!-- Installing progress — replaces action buttons -->
|
<!-- Installing progress — replaces action buttons -->
|
||||||
@ -207,7 +210,7 @@ import { computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { PackageDataEntry } from '@/types/api'
|
import type { PackageDataEntry } from '@/types/api'
|
||||||
import {
|
import {
|
||||||
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
|
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
|
||||||
getStatusClass, getStatusLabel, handleImageError,
|
getStatusClass, getStatusLabel, handleImageError,
|
||||||
} from './appsConfig'
|
} from './appsConfig'
|
||||||
import { getCuratedAppList } from '../discover/curatedApps'
|
import { getCuratedAppList } from '../discover/curatedApps'
|
||||||
@ -284,6 +287,7 @@ const isTransitioning = computed(() => {
|
|||||||
const h = props.pkg.health
|
const h = props.pkg.health
|
||||||
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
|
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
|
||||||
})
|
})
|
||||||
|
const blockedReason = computed(() => launchBlockedReason(props.id, props.pkg))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -18,15 +18,21 @@
|
|||||||
role="button"
|
role="button"
|
||||||
:tabindex="0"
|
:tabindex="0"
|
||||||
:aria-label="getTitle(id, pkg)"
|
:aria-label="getTitle(id, pkg)"
|
||||||
|
@pointerdown="startLongPress(id)"
|
||||||
|
@pointerup="clearLongPress"
|
||||||
|
@pointercancel="clearLongPress"
|
||||||
|
@pointerleave="clearLongPress"
|
||||||
|
@contextmenu.prevent="openAppOptions(id)"
|
||||||
@click="handleTap(id, pkg)"
|
@click="handleTap(id, pkg)"
|
||||||
@keydown.enter="handleTap(id, pkg)"
|
@keydown.enter="handleTap(id, pkg)"
|
||||||
|
@keydown.space.prevent="openAppOptions(id)"
|
||||||
>
|
>
|
||||||
<!-- Icon with status indicator -->
|
<!-- Icon with status indicator -->
|
||||||
<div class="app-icon-frame">
|
<div class="app-icon-frame">
|
||||||
<img
|
<img
|
||||||
:src="getIcon(id, pkg)"
|
:src="getIcon(id, pkg)"
|
||||||
:alt="getTitle(id, pkg)"
|
:alt="getTitle(id, pkg)"
|
||||||
class="app-icon-img"
|
class="app-icon-img archy-app-icon"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<!-- Status dot -->
|
<!-- Status dot -->
|
||||||
@ -44,7 +50,7 @@
|
|||||||
></span>
|
></span>
|
||||||
<!-- Installing overlay -->
|
<!-- Installing overlay -->
|
||||||
<div
|
<div
|
||||||
v-if="serverStore.isInstalling(id)"
|
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||||
class="app-icon-installing"
|
class="app-icon-installing"
|
||||||
>
|
>
|
||||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
@ -55,6 +61,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Label -->
|
<!-- Label -->
|
||||||
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||||
|
class="app-icon-progress-label"
|
||||||
|
:title="progressLabel(id, pkg)"
|
||||||
|
>
|
||||||
|
{{ progressLabel(id, pkg) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="credentialModal.show" class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6" @click.self="closeCredentialModal">
|
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
|
||||||
<div class="sideload-modal credential-modal">
|
<div class="sideload-modal credential-modal">
|
||||||
<div class="flex items-start justify-between gap-4 mb-5">
|
<div class="flex items-start justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
@ -107,6 +120,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
|||||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||||
|
import { resolveAppCredentials } from './appCredentials'
|
||||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||||
import { getCuratedAppList } from '../discover/curatedApps'
|
import { getCuratedAppList } from '../discover/curatedApps'
|
||||||
|
|
||||||
@ -135,6 +149,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const scrollContainer = ref<HTMLElement | null>(null)
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
const activePage = ref(0)
|
const activePage = ref(0)
|
||||||
|
const longPressTriggered = ref(false)
|
||||||
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const pages = computed(() => {
|
const pages = computed(() => {
|
||||||
const result: [string, PackageDataEntry][][] = []
|
const result: [string, PackageDataEntry][][] = []
|
||||||
@ -154,7 +170,22 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
|||||||
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function progressLabel(id: string, pkg: PackageDataEntry): string {
|
||||||
|
const install = serverStore.installingApps.get(id)
|
||||||
|
if (install) {
|
||||||
|
return `${install.message || 'Installing...'} ${Math.round(install.progress || 0)}%`
|
||||||
|
}
|
||||||
|
if (serverStore.uninstallingApps.has(id)) {
|
||||||
|
return pkg['uninstall-stage'] || ((pkg as unknown as Record<string, unknown>).uninstall_stage as string | undefined) || 'Removing...'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTap(id: string, pkg: PackageDataEntry) {
|
async function handleTap(id: string, pkg: PackageDataEntry) {
|
||||||
|
if (longPressTriggered.value) {
|
||||||
|
longPressTriggered.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if (canLaunch(pkg)) {
|
if (canLaunch(pkg)) {
|
||||||
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
||||||
if (shown) return
|
if (shown) return
|
||||||
@ -164,6 +195,26 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startLongPress(id: string) {
|
||||||
|
clearLongPress()
|
||||||
|
longPressTriggered.value = false
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
longPressTriggered.value = true
|
||||||
|
openAppOptions(id)
|
||||||
|
}, 550)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLongPress() {
|
||||||
|
if (!longPressTimer) return
|
||||||
|
clearTimeout(longPressTimer)
|
||||||
|
longPressTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAppOptions(id: string) {
|
||||||
|
clearLongPress()
|
||||||
|
emit('goToApp', id)
|
||||||
|
}
|
||||||
|
|
||||||
function launchNow(id: string, pkg: PackageDataEntry) {
|
function launchNow(id: string, pkg: PackageDataEntry) {
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||||
@ -191,18 +242,29 @@ function launchNow(id: string, pkg: PackageDataEntry) {
|
|||||||
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
||||||
if (!result.credentials?.length) return false
|
const credentials = resolveAppCredentials(id, result)
|
||||||
|
if (!credentials) return false
|
||||||
credentialModal.value = {
|
credentialModal.value = {
|
||||||
show: true,
|
show: true,
|
||||||
appId: id,
|
appId: id,
|
||||||
title: result.title || `${getTitle(id, pkg)} credentials`,
|
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||||
credentials: result.credentials,
|
credentials: credentials.credentials,
|
||||||
copied: '',
|
copied: '',
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
const credentials = resolveAppCredentials(id, null)
|
||||||
|
if (!credentials) return false
|
||||||
|
credentialModal.value = {
|
||||||
|
show: true,
|
||||||
|
appId: id,
|
||||||
|
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||||
|
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||||
|
credentials: credentials.credentials,
|
||||||
|
copied: '',
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,6 +332,12 @@ function scrollToPage(index: number) {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
.credential-modal {
|
||||||
|
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
.credential-modal-actions {
|
.credential-modal-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,19 @@
|
|||||||
<p class="text-white/70">
|
<p class="text-white/70">
|
||||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="deleteAppData"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-white/30 bg-black/30 text-red-500 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
|
||||||
|
<span class="block text-xs text-white/60 mt-1">{{ t('apps.deleteAppDataHelp') }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -37,7 +50,7 @@
|
|||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm', deleteAppData)"
|
||||||
:disabled="uninstalling"
|
:disabled="uninstalling"
|
||||||
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
@ -61,7 +74,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
@ -75,11 +88,12 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
confirm: []
|
confirm: [deleteAppData: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const modalRef = ref<HTMLElement | null>(null)
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||||
|
const deleteAppData = ref(false)
|
||||||
|
|
||||||
useModalKeyboard(
|
useModalKeyboard(
|
||||||
modalRef,
|
modalRef,
|
||||||
@ -87,4 +101,13 @@ useModalKeyboard(
|
|||||||
() => emit('close'),
|
() => emit('close'),
|
||||||
{ restoreFocusRef },
|
{ restoreFocusRef },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) {
|
||||||
|
deleteAppData.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-16 md:pb-4">
|
<div class="pb-16 md:pb-4">
|
||||||
<!-- Back Button -->
|
<!-- Back Button -->
|
||||||
<button @click="router.push('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
<button @click="router.replace('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<Transition name="content-fade" mode="out-in">
|
<Transition name="content-fade" mode="out-in">
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
|
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
|
||||||
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<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>
|
<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>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-else-if="error" key="error" class="glass-card p-6 text-center">
|
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
|
||||||
<p class="text-red-300 mb-4">{{ error }}</p>
|
<p class="text-red-300 mb-4">{{ error }}</p>
|
||||||
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
|
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
@ -63,6 +63,16 @@
|
|||||||
|
|
||||||
<!-- Channel List -->
|
<!-- Channel List -->
|
||||||
<div v-else key="channels" class="space-y-3">
|
<div v-else key="channels" class="space-y-3">
|
||||||
|
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5" 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>
|
||||||
|
Refreshing channels...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="ch in channels"
|
v-for="ch in channels"
|
||||||
:key="ch.chan_id || ch.channel_point"
|
:key="ch.chan_id || ch.channel_point"
|
||||||
@ -119,7 +129,7 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- Open Channel Modal -->
|
<!-- Open Channel Modal -->
|
||||||
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="showOpenModal = false">
|
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
|
||||||
<div class="glass-card p-6 w-full max-w-md mx-4">
|
<div class="glass-card p-6 w-full max-w-md mx-4">
|
||||||
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
|
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
|
||||||
|
|
||||||
@ -165,7 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Close Confirmation Modal -->
|
<!-- Close Confirmation Modal -->
|
||||||
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="closeTarget = null">
|
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
|
||||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||||
@ -232,6 +242,7 @@ function capacityPercent(amount: number, capacity: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadChannels() {
|
async function loadChannels() {
|
||||||
|
const hadChannels = channels.value.length > 0
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@ -246,6 +257,7 @@ async function loadChannels() {
|
|||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||||
|
if (!hadChannels) channels.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -305,4 +317,6 @@ async function closeChannel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadChannels)
|
onMounted(loadChannels)
|
||||||
|
|
||||||
|
defineExpose({ channels, loadChannels })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import AppIconGrid from '../AppIconGrid.vue'
|
import AppIconGrid from '../AppIconGrid.vue'
|
||||||
|
|
||||||
const mockWindowOpen = vi.fn()
|
const mockWindowOpen = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
call: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
vi.stubGlobal('open', mockWindowOpen)
|
vi.stubGlobal('open', mockWindowOpen)
|
||||||
|
|
||||||
function makePkg(id: string): PackageDataEntry {
|
function makePkg(id: string): PackageDataEntry {
|
||||||
@ -31,8 +38,12 @@ function makePkg(id: string): PackageDataEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AppIconGrid', () => {
|
describe('AppIconGrid', () => {
|
||||||
|
let pinia: ReturnType<typeof createPinia>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
vi.useRealTimers()
|
||||||
|
pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
@ -51,14 +62,32 @@ describe('AppIconGrid', () => {
|
|||||||
const wrapper = mount(AppIconGrid, {
|
const wrapper = mount(AppIconGrid, {
|
||||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||||
global: {
|
global: {
|
||||||
plugins: [createPinia()],
|
plugins: [pinia],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.get('.app-icon-item').trigger('click')
|
await wrapper.get('.app-icon-item').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
expect(useAppLauncherStore(pinia).panelAppId).toBe('lnd')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows File Browser credentials before launch even when backend returns no credentials', async () => {
|
||||||
|
const wrapper = mount(AppIconGrid, {
|
||||||
|
props: { apps: [['filebrowser', makePkg('filebrowser')]] },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('.app-icon-item').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('File Browser credentials')
|
||||||
|
expect(wrapper.text()).toContain('Username')
|
||||||
|
expect(wrapper.text()).toContain('admin')
|
||||||
|
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes desktop new-tab apps through app session on mobile', async () => {
|
it('routes desktop new-tab apps through app session on mobile', async () => {
|
||||||
@ -71,13 +100,78 @@ describe('AppIconGrid', () => {
|
|||||||
const wrapper = mount(AppIconGrid, {
|
const wrapper = mount(AppIconGrid, {
|
||||||
props: { apps: [['gitea', makePkg('gitea')]] },
|
props: { apps: [['gitea', makePkg('gitea')]] },
|
||||||
global: {
|
global: {
|
||||||
plugins: [createPinia()],
|
plugins: [pinia],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.get('.app-icon-item').trigger('click')
|
await wrapper.get('.app-icon-item').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
expect(useAppLauncherStore().panelAppId).toBeNull()
|
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows backend uninstall stage while an app is removing', () => {
|
||||||
|
const pkg = makePkg('indeedhub')
|
||||||
|
pkg.state = PackageState.Removing
|
||||||
|
pkg['uninstall-stage'] = 'Stopping containers (2/7)'
|
||||||
|
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||||
|
|
||||||
|
const wrapper = mount(AppIconGrid, {
|
||||||
|
props: { apps: [['indeedhub', pkg]] },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Stopping containers (2/7)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports legacy underscore uninstall stage data', () => {
|
||||||
|
const pkg = makePkg('indeedhub')
|
||||||
|
pkg.state = PackageState.Removing
|
||||||
|
;(pkg as PackageDataEntry & { uninstall_stage?: string }).uninstall_stage = 'Removing app data'
|
||||||
|
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||||
|
|
||||||
|
const wrapper = mount(AppIconGrid, {
|
||||||
|
props: { apps: [['indeedhub', pkg]] },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Removing app data')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens app details on long press without launching the app', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const wrapper = mount(AppIconGrid, {
|
||||||
|
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const icon = wrapper.get('.app-icon-item')
|
||||||
|
await icon.trigger('pointerdown')
|
||||||
|
vi.advanceTimersByTime(550)
|
||||||
|
await icon.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||||
|
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens app details from the keyboard options shortcut', async () => {
|
||||||
|
const wrapper = mount(AppIconGrid, {
|
||||||
|
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('.app-icon-item').trigger('keydown.space')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
37
neode-ui/src/views/apps/__tests__/AppsUninstallModal.test.ts
Normal file
37
neode-ui/src/views/apps/__tests__/AppsUninstallModal.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, params?: Record<string, string>) => {
|
||||||
|
if (params?.name) return `${key} ${params.name}`
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||||
|
useModalKeyboard: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AppsUninstallModal', () => {
|
||||||
|
it('emits the delete-data choice when uninstall is confirmed', async () => {
|
||||||
|
const wrapper = mount(AppsUninstallModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
appTitle: 'File Browser',
|
||||||
|
uninstalling: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||||
|
expect(checkbox).not.toBeNull()
|
||||||
|
checkbox?.click()
|
||||||
|
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
|
||||||
|
expect(confirmButton).not.toBeNull()
|
||||||
|
confirmButton?.click()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('confirm')?.[0]).toEqual([true])
|
||||||
|
})
|
||||||
|
})
|
||||||
70
neode-ui/src/views/apps/__tests__/LightningChannels.test.ts
Normal file
70
neode-ui/src/views/apps/__tests__/LightningChannels.test.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import LightningChannels from '../LightningChannels.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ replace: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
call: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChannel() {
|
||||||
|
return {
|
||||||
|
chan_id: '123',
|
||||||
|
remote_pubkey: 'peer-pubkey',
|
||||||
|
capacity: 100_000,
|
||||||
|
local_balance: 60_000,
|
||||||
|
remote_balance: 40_000,
|
||||||
|
active: true,
|
||||||
|
status: 'active',
|
||||||
|
channel_point: 'txid:0',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LightningChannels', () => {
|
||||||
|
it('keeps channels visible while refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||||
|
channels: [makeChannel()],
|
||||||
|
total_inbound: 40_000,
|
||||||
|
total_outbound: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(LightningChannels)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('peer-pubkey')
|
||||||
|
expect(wrapper.text()).toContain('100.0k sats')
|
||||||
|
|
||||||
|
const pending = deferred<{ channels: []; total_inbound: number; total_outbound: number }>()
|
||||||
|
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { loadChannels: () => Promise<void> }).loadChannels()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('peer-pubkey')
|
||||||
|
expect(wrapper.text()).toContain('Refreshing channels...')
|
||||||
|
expect(wrapper.text()).not.toContain('Loading channels...')
|
||||||
|
|
||||||
|
pending.reject(new Error('offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('peer-pubkey')
|
||||||
|
expect(wrapper.text()).toContain('offline')
|
||||||
|
})
|
||||||
|
})
|
||||||
35
neode-ui/src/views/apps/__tests__/appCredentials.test.ts
Normal file
35
neode-ui/src/views/apps/__tests__/appCredentials.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { resolveAppCredentials } from '../appCredentials'
|
||||||
|
|
||||||
|
describe('resolveAppCredentials', () => {
|
||||||
|
it('uses backend credentials when they are available', () => {
|
||||||
|
expect(resolveAppCredentials('filebrowser', {
|
||||||
|
title: 'Backend credentials',
|
||||||
|
credentials: [{ label: 'Password', value: 'secret' }],
|
||||||
|
})?.credentials[0].value).toBe('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to File Browser default credentials when backend data is not available', () => {
|
||||||
|
const result = resolveAppCredentials('filebrowser', { credentials: [] })
|
||||||
|
|
||||||
|
expect(result?.title).toBe('File Browser credentials')
|
||||||
|
expect(result?.credentials).toEqual([
|
||||||
|
{ label: 'Username', value: 'admin' },
|
||||||
|
{ label: 'Password', value: 'admin', sensitive: true },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to PhotoPrism manifest credentials when backend data is not available', () => {
|
||||||
|
const result = resolveAppCredentials('photoprism', { credentials: [] })
|
||||||
|
|
||||||
|
expect(result?.title).toBe('PhotoPrism credentials')
|
||||||
|
expect(result?.credentials).toEqual([
|
||||||
|
{ label: 'Username', value: 'admin' },
|
||||||
|
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not invent credentials for unknown apps', () => {
|
||||||
|
expect(resolveAppCredentials('unknown', { credentials: [] })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
55
neode-ui/src/views/apps/__tests__/appPackageCache.test.ts
Normal file
55
neode-ui/src/views/apps/__tests__/appPackageCache.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
|
import { useLastKnownPackages, type PackageMap } from '../appPackageCache'
|
||||||
|
|
||||||
|
function makePkg(id: string): PackageDataEntry {
|
||||||
|
return {
|
||||||
|
state: PackageState.Running,
|
||||||
|
manifest: {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
version: '1.0.0',
|
||||||
|
description: { short: '', long: '' },
|
||||||
|
'release-notes': '',
|
||||||
|
license: '',
|
||||||
|
'wrapper-repo': '',
|
||||||
|
'upstream-repo': '',
|
||||||
|
'support-site': '',
|
||||||
|
'marketing-site': '',
|
||||||
|
'donation-url': null,
|
||||||
|
},
|
||||||
|
'static-files': { license: '', instructions: '', icon: '' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLastKnownPackages', () => {
|
||||||
|
it('keeps the last package list visible while the scanner reports not ready', async () => {
|
||||||
|
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||||
|
const containersScanned = ref(true)
|
||||||
|
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||||
|
|
||||||
|
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||||
|
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||||
|
|
||||||
|
containersScanned.value = false
|
||||||
|
livePackages.value = {}
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||||
|
expect(cache.isUsingLastKnownPackages.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an empty list once the scanner has completed', async () => {
|
||||||
|
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||||
|
const containersScanned = ref(true)
|
||||||
|
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||||
|
|
||||||
|
livePackages.value = {}
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(cache.packages.value).toEqual({})
|
||||||
|
expect(cache.lastKnownPackages.value).toEqual({})
|
||||||
|
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
import { filterEntriesForTab, isServiceContainer, isServicePackage, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
|
import { canLaunch, filterEntriesForTab, isServiceContainer, isServicePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
|
||||||
|
|
||||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||||
return {
|
return {
|
||||||
@ -81,4 +81,12 @@ describe('appsConfig service filtering', () => {
|
|||||||
pkg['static-files']!.icon = 'git-branch'
|
pkg['static-files']!.icon = 'git-branch'
|
||||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
||||||
|
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
||||||
|
pkg.state = PackageState.Starting
|
||||||
|
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8175' } } } as unknown as PackageDataEntry['installed']
|
||||||
|
expect(launchBlockedReason('fedimint', pkg)).toContain('Bitcoin')
|
||||||
|
expect(canLaunch(pkg)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
67
neode-ui/src/views/apps/__tests__/sideloadValidation.test.ts
Normal file
67
neode-ui/src/views/apps/__tests__/sideloadValidation.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
|
import { parseSideloadPortMapping, validateSideloadRequest } from '../sideloadValidation'
|
||||||
|
|
||||||
|
function makePkg(id: string, title: string, lanAddress?: string): PackageDataEntry {
|
||||||
|
return {
|
||||||
|
state: PackageState.Running,
|
||||||
|
manifest: {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
version: '1.0.0',
|
||||||
|
description: { short: '', long: '' },
|
||||||
|
'release-notes': '',
|
||||||
|
license: '',
|
||||||
|
'wrapper-repo': '',
|
||||||
|
'upstream-repo': '',
|
||||||
|
'support-site': '',
|
||||||
|
'marketing-site': '',
|
||||||
|
'donation-url': null,
|
||||||
|
},
|
||||||
|
installed: lanAddress
|
||||||
|
? {
|
||||||
|
'current-dependents': {},
|
||||||
|
'current-dependencies': {},
|
||||||
|
'last-backup': null,
|
||||||
|
status: 'running',
|
||||||
|
'interface-addresses': {
|
||||||
|
main: {
|
||||||
|
'lan-address': lanAddress,
|
||||||
|
'tor-address': '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sideloadValidation', () => {
|
||||||
|
it('parses host and container port mappings', () => {
|
||||||
|
expect(parseSideloadPortMapping('3009:80')).toEqual({ host: 3009, container: 80 })
|
||||||
|
expect(parseSideloadPortMapping('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed port mappings', () => {
|
||||||
|
expect(() => parseSideloadPortMapping('3009')).toThrow('host:container')
|
||||||
|
expect(() => parseSideloadPortMapping('99999:80')).toThrow('between 1 and 65535')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate app IDs', () => {
|
||||||
|
const packages = { excalidraw: makePkg('excalidraw', 'Excalidraw') }
|
||||||
|
expect(validateSideloadRequest('excalidraw', '3009:80', packages)).toContain('already installed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects reserved host ports', () => {
|
||||||
|
expect(validateSideloadRequest('demo', '9000:80', {})).toContain('reserved')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects host ports already used by installed apps', () => {
|
||||||
|
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||||
|
expect(validateSideloadRequest('demo', '8083:80', packages)).toContain('File Browser')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts available host ports', () => {
|
||||||
|
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||||
|
expect(validateSideloadRequest('demo', '3018:80', packages)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
25
neode-ui/src/views/apps/appCredentials.ts
Normal file
25
neode-ui/src/views/apps/appCredentials.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { AppCredentialsResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const FALLBACK_CREDENTIALS: Record<string, AppCredentialsResponse> = {
|
||||||
|
filebrowser: {
|
||||||
|
title: 'File Browser credentials',
|
||||||
|
description: 'Use these credentials when File Browser asks you to sign in.',
|
||||||
|
credentials: [
|
||||||
|
{ label: 'Username', value: 'admin' },
|
||||||
|
{ label: 'Password', value: 'admin', sensitive: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
photoprism: {
|
||||||
|
title: 'PhotoPrism credentials',
|
||||||
|
description: 'Use these credentials when PhotoPrism asks you to sign in.',
|
||||||
|
credentials: [
|
||||||
|
{ label: 'Username', value: 'admin' },
|
||||||
|
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAppCredentials(appId: string, response?: AppCredentialsResponse | null): AppCredentialsResponse | null {
|
||||||
|
if (response?.credentials?.length) return response
|
||||||
|
return FALLBACK_CREDENTIALS[appId] ?? null
|
||||||
|
}
|
||||||
38
neode-ui/src/views/apps/appPackageCache.ts
Normal file
38
neode-ui/src/views/apps/appPackageCache.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { computed, ref, watch, type Ref } from 'vue'
|
||||||
|
import type { PackageDataEntry } from '@/types/api'
|
||||||
|
|
||||||
|
export type PackageMap = Record<string, PackageDataEntry>
|
||||||
|
|
||||||
|
export function useLastKnownPackages(
|
||||||
|
livePackages: Ref<PackageMap>,
|
||||||
|
containersScanned: Ref<boolean>,
|
||||||
|
) {
|
||||||
|
const lastKnownPackages = ref<PackageMap>({})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
livePackages,
|
||||||
|
(packages) => {
|
||||||
|
const hasPackages = Object.keys(packages).length > 0
|
||||||
|
if (hasPackages || containersScanned.value) {
|
||||||
|
lastKnownPackages.value = { ...packages }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const isUsingLastKnownPackages = computed(() => (
|
||||||
|
!containersScanned.value &&
|
||||||
|
Object.keys(livePackages.value).length === 0 &&
|
||||||
|
Object.keys(lastKnownPackages.value).length > 0
|
||||||
|
))
|
||||||
|
|
||||||
|
const packages = computed<PackageMap>(() => (
|
||||||
|
isUsingLastKnownPackages.value ? lastKnownPackages.value : livePackages.value
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
packages,
|
||||||
|
isUsingLastKnownPackages,
|
||||||
|
lastKnownPackages,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -50,7 +50,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
|||||||
// Known app -> category mappings (matches App Store categorisation)
|
// Known app -> category mappings (matches App Store categorisation)
|
||||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||||
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
|
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||||
@ -165,6 +165,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
|
|||||||
gitea: '/assets/img/app-icons/gitea.svg',
|
gitea: '/assets/img/app-icons/gitea.svg',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
||||||
|
|
||||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||||
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
||||||
@ -184,9 +186,23 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
|
|||||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||||
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||||
|
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||||
|
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||||
|
}
|
||||||
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
|
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||||
|
const appId = pkg?.manifest?.id || id
|
||||||
|
if (
|
||||||
|
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||||
|
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
||||||
|
) {
|
||||||
|
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||||
const addr = runtimeLanAddress(pkg)
|
const addr = runtimeLanAddress(pkg)
|
||||||
if (!addr || typeof window === 'undefined') return addr
|
if (!addr || typeof window === 'undefined') return addr
|
||||||
@ -272,14 +288,8 @@ export function handleImageError(e: Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
|
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
|
||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
target.src = DEFAULT_APP_ICON
|
||||||
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
|
target.dataset.defaultIcon = "1"
|
||||||
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
|
|
||||||
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
|
|
||||||
</svg>
|
|
||||||
`)}`
|
|
||||||
if (!currentSrc.includes("data:image")) {
|
|
||||||
target.src = placeholderSvg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
neode-ui/src/views/apps/sideloadValidation.ts
Normal file
85
neode-ui/src/views/apps/sideloadValidation.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import type { PackageDataEntry } from '@/types/api'
|
||||||
|
|
||||||
|
const RESERVED_HOST_PORTS = new Set([
|
||||||
|
80, 443, 81,
|
||||||
|
8332, 8333, 8334,
|
||||||
|
9735, 10009, 8080,
|
||||||
|
18083,
|
||||||
|
4080, 8999, 50001,
|
||||||
|
23000,
|
||||||
|
8173, 8174, 8175,
|
||||||
|
8123,
|
||||||
|
3000,
|
||||||
|
11434,
|
||||||
|
9980, 9001,
|
||||||
|
8240,
|
||||||
|
9000,
|
||||||
|
3001, 3002,
|
||||||
|
8888,
|
||||||
|
8096, 2342, 2283,
|
||||||
|
8443,
|
||||||
|
])
|
||||||
|
|
||||||
|
export interface ParsedPortMapping {
|
||||||
|
host: number
|
||||||
|
container: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSideloadPortMapping(value: string): ParsedPortMapping | null {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const match = trimmed.match(/^(\d{1,5}):(\d{1,5})$/)
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Port mapping must use host:container format, for example 3009:80.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = Number(match[1])
|
||||||
|
const container = Number(match[2])
|
||||||
|
if (!Number.isInteger(host) || host < 1 || host > 65535 || !Number.isInteger(container) || container < 1 || container > 65535) {
|
||||||
|
throw new Error('Ports must be between 1 and 65535.')
|
||||||
|
}
|
||||||
|
return { host, container }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packageUsesHostPort(pkg: PackageDataEntry, hostPort: number): boolean {
|
||||||
|
const addresses = pkg.installed?.['interface-addresses'] || {}
|
||||||
|
return Object.values(addresses).some((addr) => {
|
||||||
|
const lan = addr?.['lan-address']
|
||||||
|
if (!lan) return false
|
||||||
|
try {
|
||||||
|
const parsed = new URL(lan)
|
||||||
|
return Number(parsed.port || (parsed.protocol === 'https:' ? '443' : '80')) === hostPort
|
||||||
|
} catch {
|
||||||
|
const match = lan.match(/:(\d+)(?:\/|$)/)
|
||||||
|
return match ? Number(match[1]) === hostPort : false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSideloadRequest(
|
||||||
|
id: string,
|
||||||
|
portMapping: string,
|
||||||
|
packages: Record<string, PackageDataEntry>,
|
||||||
|
): string | null {
|
||||||
|
if (packages[id]) return `An app with ID "${id}" is already installed.`
|
||||||
|
|
||||||
|
let parsed: ParsedPortMapping | null = null
|
||||||
|
try {
|
||||||
|
parsed = parseSideloadPortMapping(portMapping)
|
||||||
|
} catch (err) {
|
||||||
|
return err instanceof Error ? err.message : 'Invalid port mapping.'
|
||||||
|
}
|
||||||
|
if (!parsed) return null
|
||||||
|
|
||||||
|
if (RESERVED_HOST_PORTS.has(parsed.host)) {
|
||||||
|
return `Host port ${parsed.host} is reserved by Archipelago or a packaged app. Choose another host port.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = Object.entries(packages).find(([, pkg]) => packageUsesHostPort(pkg, parsed.host))
|
||||||
|
if (existing) {
|
||||||
|
const title = existing[1].manifest?.title || existing[0]
|
||||||
|
return `Host port ${parsed.host} is already used by ${title}. Choose another host port.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -70,11 +70,11 @@ export function useAppsActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmUninstall(appId: string) {
|
async function confirmUninstall(appId: string, options: { preserveData?: boolean } = {}) {
|
||||||
uninstalling.value = true
|
uninstalling.value = true
|
||||||
try {
|
try {
|
||||||
uninstallingApps.add(appId)
|
uninstallingApps.add(appId)
|
||||||
await store.uninstallPackage(appId)
|
await store.uninstallPackage(appId, options)
|
||||||
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
||||||
// when the container actually disappears from backend data
|
// when the container actually disappears from backend data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
13
neode-ui/src/views/cloudPath.ts
Normal file
13
neode-ui/src/views/cloudPath.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export function normalizeCloudPath(path: unknown, fallback = '/'): string {
|
||||||
|
if (typeof path !== 'string' || !path.trim()) return fallback
|
||||||
|
const trimmed = path.trim()
|
||||||
|
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||||
|
return withSlash.replace(/\/+/g, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parentCloudPath(path: string): string {
|
||||||
|
const normalized = normalizeCloudPath(path)
|
||||||
|
if (normalized === '/') return '/'
|
||||||
|
const parent = normalized.slice(0, normalized.lastIndexOf('/')) || '/'
|
||||||
|
return parent
|
||||||
|
}
|
||||||
@ -41,11 +41,30 @@ import { useAppStore } from '@/stores/app'
|
|||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
|
const HEALTH_NOTIFICATION_MAX_AGE_MS = 30 * 60 * 1000
|
||||||
|
const GENERIC_NOTIFICATION_MAX_AGE_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
const dismissedNotifications = ref<Set<string>>(new Set())
|
const dismissedNotifications = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const healthNotifications = computed(() => {
|
const healthNotifications = computed(() => {
|
||||||
const notifs = store.data?.notifications ?? []
|
const notifs = store.data?.notifications ?? []
|
||||||
const visible = notifs.filter(n => !dismissedNotifications.value.has(n.id))
|
const packages = store.data?.['package-data'] ?? {}
|
||||||
|
const visible = notifs.filter((n) => {
|
||||||
|
if (dismissedNotifications.value.has(n.id)) return false
|
||||||
|
|
||||||
|
const appId = n.app_id || appIdFromNotificationTitle(n.title)
|
||||||
|
if (appId) {
|
||||||
|
if (isOlderThan(n.timestamp, HEALTH_NOTIFICATION_MAX_AGE_MS)) return false
|
||||||
|
const pkg = packages[appId]
|
||||||
|
if (!pkg) return false
|
||||||
|
if (pkg.health !== 'unhealthy') return false
|
||||||
|
if (pkg.state === 'removing' || pkg.state === 'stopped' || pkg.state === 'exited') return false
|
||||||
|
} else if (isOlderThan(n.timestamp, GENERIC_NOTIFICATION_MAX_AGE_MS)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
// Deduplicate: keep only the latest notification per container/title
|
// Deduplicate: keep only the latest notification per container/title
|
||||||
const seen = new Map<string, typeof visible[0]>()
|
const seen = new Map<string, typeof visible[0]>()
|
||||||
for (const n of visible) {
|
for (const n of visible) {
|
||||||
@ -64,4 +83,14 @@ function dismissNotification(id: string) {
|
|||||||
}
|
}
|
||||||
dismissedNotifications.value.add(id)
|
dismissedNotifications.value.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appIdFromNotificationTitle(title: string): string | undefined {
|
||||||
|
const suffix = ' is unhealthy'
|
||||||
|
return title.endsWith(suffix) ? title.slice(0, -suffix.length) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOlderThan(timestamp: string, maxAgeMs: number): boolean {
|
||||||
|
const ts = Date.parse(timestamp)
|
||||||
|
return Number.isFinite(ts) && Date.now() - ts > maxAgeMs
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { PackageState, type DataModel, type PackageDataEntry } from '@/types/api'
|
||||||
|
import HealthNotifications from '../HealthNotifications.vue'
|
||||||
|
|
||||||
|
function makePkg(id: string, state: PackageState = PackageState.Running, health: string | null = 'healthy'): PackageDataEntry {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
health,
|
||||||
|
manifest: {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
version: '1.0.0',
|
||||||
|
description: { short: '', long: '' },
|
||||||
|
'release-notes': '',
|
||||||
|
license: '',
|
||||||
|
'wrapper-repo': '',
|
||||||
|
'upstream-repo': '',
|
||||||
|
'support-site': '',
|
||||||
|
'marketing-site': '',
|
||||||
|
'donation-url': null,
|
||||||
|
interfaces: { main: { ui: true } },
|
||||||
|
} as unknown as PackageDataEntry['manifest'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeData(pkg?: PackageDataEntry, timestamp = new Date().toISOString()): DataModel {
|
||||||
|
return {
|
||||||
|
'server-info': {
|
||||||
|
id: 'node',
|
||||||
|
version: '1.0.0',
|
||||||
|
name: null,
|
||||||
|
pubkey: '',
|
||||||
|
'status-info': {
|
||||||
|
restarting: false,
|
||||||
|
'shutting-down': false,
|
||||||
|
updated: false,
|
||||||
|
'backup-progress': null,
|
||||||
|
'update-progress': null,
|
||||||
|
},
|
||||||
|
'lan-address': null,
|
||||||
|
'tor-address': null,
|
||||||
|
unread: 0,
|
||||||
|
'wifi-ssids': [],
|
||||||
|
'zram-enabled': false,
|
||||||
|
'seed-backed': false,
|
||||||
|
},
|
||||||
|
'package-data': pkg ? { indeedhub: pkg } : {},
|
||||||
|
notifications: [{
|
||||||
|
id: 'health-1',
|
||||||
|
level: 'error',
|
||||||
|
title: 'indeedhub is unhealthy',
|
||||||
|
message: 'indeedhub health check failed',
|
||||||
|
timestamp,
|
||||||
|
app_id: 'indeedhub',
|
||||||
|
}],
|
||||||
|
ui: {
|
||||||
|
name: null,
|
||||||
|
'ack-welcome': '',
|
||||||
|
marketplace: {
|
||||||
|
'selected-hosts': [],
|
||||||
|
'known-hosts': {},
|
||||||
|
},
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HealthNotifications', () => {
|
||||||
|
let pinia: ReturnType<typeof createPinia>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows active unhealthy package notifications', () => {
|
||||||
|
const store = useAppStore(pinia)
|
||||||
|
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'unhealthy'))
|
||||||
|
|
||||||
|
const wrapper = mount(HealthNotifications, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('indeedhub is unhealthy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides stale package notifications once health recovers', () => {
|
||||||
|
const store = useAppStore(pinia)
|
||||||
|
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'healthy'))
|
||||||
|
|
||||||
|
const wrapper = mount(HealthNotifications, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides package notifications while an app is being removed', () => {
|
||||||
|
const store = useAppStore(pinia)
|
||||||
|
store.data = makeData(makePkg('indeedhub', PackageState.Removing, 'unhealthy'))
|
||||||
|
|
||||||
|
const wrapper = mount(HealthNotifications, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides old package health notifications on reload even if the app is still unhealthy', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||||
|
|
||||||
|
const store = useAppStore(pinia)
|
||||||
|
store.data = makeData(
|
||||||
|
makePkg('indeedhub', PackageState.Running, 'unhealthy'),
|
||||||
|
new Date('2026-06-10T11:20:00Z').toISOString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const wrapper = mount(HealthNotifications, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -2,6 +2,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- Apps Grid -->
|
<!-- Apps Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8">
|
||||||
|
<template v-if="isLoading && filteredApps.length === 0">
|
||||||
|
<div
|
||||||
|
v-for="index in 6"
|
||||||
|
:key="`loading-${index}`"
|
||||||
|
class="glass-card p-5 flex flex-col app-card-skeleton"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4 mb-3">
|
||||||
|
<div class="app-card-skeleton-icon"></div>
|
||||||
|
<div class="flex-1 min-w-0 pt-1">
|
||||||
|
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||||
|
<div class="app-card-skeleton-line w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||||
|
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||||
|
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||||
|
<div class="app-card-skeleton-button mt-auto"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div
|
<div
|
||||||
v-for="(app, index) in filteredApps"
|
v-for="(app, index) in filteredApps"
|
||||||
:key="app.id"
|
:key="app.id"
|
||||||
@ -124,15 +144,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="filteredApps.length === 0" class="text-center py-12">
|
<div v-if="filteredApps.length === 0 && !isLoading" class="text-center py-12">
|
||||||
<div v-if="isLoading" class="flex flex-col items-center gap-4">
|
<div v-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
|
||||||
<svg class="animate-spin h-12 w-12 text-blue-400" xmlns="http://www.w3.org/2000/svg" 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>
|
|
||||||
<p class="text-white/70">{{ loadingMessage }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
|
|
||||||
<p class="text-white/70">No community apps found</p>
|
<p class="text-white/70">No community apps found</p>
|
||||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||||
<button @click="$emit('retry-nostr')" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
<button @click="$emit('retry-nostr')" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
||||||
@ -196,4 +209,44 @@ function handleImageError(event: Event) {
|
|||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton {
|
||||||
|
min-height: 245px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-icon,
|
||||||
|
.app-card-skeleton-line,
|
||||||
|
.app-card-skeleton-button {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-icon::after,
|
||||||
|
.app-card-skeleton-line::after,
|
||||||
|
.app-card-skeleton-button::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||||
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-icon {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-line {
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-skeleton-button {
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showFilter"
|
v-if="showFilter"
|
||||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
|
||||||
@click.self="closeFilter"
|
@click.self="closeFilter"
|
||||||
>
|
>
|
||||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||||
|
|||||||
@ -79,7 +79,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
|||||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||||
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing. Storefront opens on port 9011; admin dashboard remains on 9010.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
|
|
||||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||||
@ -87,7 +86,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
|||||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||||
{ id: 'nextcloud', title: 'Nextcloud', version: '28', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:28`, repoUrl: 'https://github.com/nextcloud/server' },
|
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||||
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
|
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
|
||||||
@ -121,7 +120,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
|||||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||||
bitcoin: ['bitcoin-knots'],
|
bitcoin: ['bitcoin-knots'],
|
||||||
btcpay: ['btcpay-server'],
|
btcpay: ['btcpay-server'],
|
||||||
saleor: ['saleor'],
|
|
||||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||||
fedimint: ['fedimint-gateway'],
|
fedimint: ['fedimint-gateway'],
|
||||||
@ -191,7 +189,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
|||||||
const combined = `${id} ${title} ${description}`
|
const combined = `${id} ${title} ${description}`
|
||||||
|
|
||||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
||||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
||||||
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||||
|
|||||||
@ -67,6 +67,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
|
<div v-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-3.5 w-3.5" 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 relays...
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="node in nodes"
|
v-for="node in nodes"
|
||||||
:key="node.nostr_pubkey"
|
:key="node.nostr_pubkey"
|
||||||
@ -189,4 +196,6 @@ function shortNpub(npub: string): string {
|
|||||||
if (!npub || npub.length < 16) return npub
|
if (!npub || npub.length < 16) return npub
|
||||||
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="handleClose">
|
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="handleClose">
|
||||||
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-white">Node Details</h2>
|
<h2 class="text-xl font-semibold text-white">Node Details</h2>
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
|
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
|
||||||
<h2 class="text-lg font-semibold text-white mb-4">Your Nodes <span v-if="trustedNodes.length > 0" class="text-sm font-normal text-white/50">({{ trustedNodes.length }})</span></h2>
|
<h2 class="text-lg font-semibold text-white mb-4">Your Nodes <span v-if="trustedNodes.length > 0" class="text-sm font-normal text-white/50">({{ trustedNodes.length }})</span></h2>
|
||||||
|
|
||||||
<div v-if="loading" class="flex items-center gap-3 py-8 justify-center">
|
<div v-if="loading && nodes.length === 0" class="flex items-center gap-3 py-8 justify-center">
|
||||||
<div class="w-5 h-5 border-2 border-white/20 border-t-orange-400 rounded-full animate-spin"></div>
|
<div class="w-5 h-5 border-2 border-white/20 border-t-orange-400 rounded-full animate-spin"></div>
|
||||||
<span class="text-white/60 text-sm">Loading nodes...</span>
|
<span class="text-white/60 text-sm">Loading nodes...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import DiscoverModal from '../DiscoverModal.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
handshakeDiscover: vi.fn(),
|
||||||
|
handshakeConnect: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode() {
|
||||||
|
return {
|
||||||
|
nostr_pubkey: 'pubkey-one',
|
||||||
|
nostr_npub: 'npub1abcdefghijklmnopqrstuvwxyz',
|
||||||
|
did: 'did:key:node',
|
||||||
|
version: '1.8-alpha',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DiscoverModal', () => {
|
||||||
|
it('keeps discoverable nodes visible while relay refresh is pending or fails', async () => {
|
||||||
|
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
|
||||||
|
|
||||||
|
const wrapper = mount(DiscoverModal, {
|
||||||
|
props: { visible: false, outboundSent: [] },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Teleport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ visible: true })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('did:key:node')
|
||||||
|
expect(wrapper.text()).toContain('version 1.8-alpha')
|
||||||
|
|
||||||
|
const pending = deferred<{ nodes: [] }>()
|
||||||
|
vi.mocked(rpcClient.handshakeDiscover).mockReturnValueOnce(pending.promise)
|
||||||
|
|
||||||
|
const refresh = (wrapper.vm as unknown as { refresh: () => Promise<void> }).refresh()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('did:key:node')
|
||||||
|
expect(wrapper.text()).toContain('Searching relays...')
|
||||||
|
expect(wrapper.text()).not.toContain('No discoverable nodes found')
|
||||||
|
|
||||||
|
pending.reject(new Error('relay offline'))
|
||||||
|
await refresh
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('did:key:node')
|
||||||
|
expect(wrapper.text()).toContain('relay offline')
|
||||||
|
expect(wrapper.text()).not.toContain('Searching relays...')
|
||||||
|
expect(wrapper.text()).not.toContain('No discoverable nodes found')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
neode-ui/src/views/federation/__tests__/NodeList.test.ts
Normal file
32
neode-ui/src/views/federation/__tests__/NodeList.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import NodeList from '../NodeList.vue'
|
||||||
|
import type { FederatedNode } from '../types'
|
||||||
|
|
||||||
|
const trustedNode: FederatedNode = {
|
||||||
|
did: 'did:key:z6MkTrustedNode',
|
||||||
|
pubkey: 'trusted-pubkey',
|
||||||
|
onion: 'trusted.onion',
|
||||||
|
trust_level: 'trusted',
|
||||||
|
added_at: '2026-06-10T00:00:00Z',
|
||||||
|
name: 'Trusted Node',
|
||||||
|
last_seen: '2026-06-10T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NodeList', () => {
|
||||||
|
it('keeps existing nodes visible during background refresh loading', () => {
|
||||||
|
const wrapper = mount(NodeList, {
|
||||||
|
props: {
|
||||||
|
nodes: [trustedNode],
|
||||||
|
loading: true,
|
||||||
|
error: '',
|
||||||
|
syncResults: [],
|
||||||
|
dwnSyncDotClass: 'bg-green-400',
|
||||||
|
cleaningNodes: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Trusted Node')
|
||||||
|
expect(wrapper.text()).not.toContain('Loading nodes...')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -28,9 +28,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History Charts -->
|
<!-- History Charts -->
|
||||||
<div v-if="historyLoading" class="text-white/40 text-sm py-4 text-center mb-4">
|
<div v-if="historyLoading && !historyLabels.length" class="text-white/40 text-sm py-4 text-center mb-4">
|
||||||
Loading history...
|
Loading history...
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="historyLoading" class="text-white/40 text-xs text-center mb-4">
|
||||||
|
Refreshing history...
|
||||||
|
</div>
|
||||||
<div v-else-if="historyLabels.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
<div v-else-if="historyLabels.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
<div class="glass-card p-4">
|
<div class="glass-card p-4">
|
||||||
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>
|
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>
|
||||||
|
|||||||
80
neode-ui/src/views/fleet/__tests__/useFleetData.test.ts
Normal file
80
neode-ui/src/views/fleet/__tests__/useFleetData.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
|
||||||
|
|
||||||
|
function node(id: string, reportedAt: string): FleetNode {
|
||||||
|
return {
|
||||||
|
node_id: id,
|
||||||
|
version: '1.8-alpha',
|
||||||
|
uptime_secs: 60,
|
||||||
|
cpu_cores: 4,
|
||||||
|
cpu_pct: 10,
|
||||||
|
mem_pct: 20,
|
||||||
|
disk_pct: 30,
|
||||||
|
container_count: 2,
|
||||||
|
running_count: 2,
|
||||||
|
federation_peers: 1,
|
||||||
|
recent_alerts: [],
|
||||||
|
containers: [],
|
||||||
|
reported_at: reportedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fleet data helpers', () => {
|
||||||
|
it('treats nodes reported within 30 minutes as online', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||||
|
|
||||||
|
expect(isOnline('2026-06-10T11:45:00Z')).toBe(true)
|
||||||
|
expect(isOnline('2026-06-10T11:20:00Z')).toBe(false)
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts status with online nodes first, then latest report', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||||
|
const nodes = [
|
||||||
|
node('offline', '2026-06-10T10:00:00Z'),
|
||||||
|
node('online-old', '2026-06-10T11:45:00Z'),
|
||||||
|
node('online-new', '2026-06-10T11:59:00Z'),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
|
||||||
|
'online-new',
|
||||||
|
'online-old',
|
||||||
|
'offline',
|
||||||
|
])
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts by name alphabetically', () => {
|
||||||
|
expect(sortFleetNodes([
|
||||||
|
node('zulu', '2026-06-10T11:59:00Z'),
|
||||||
|
node('alpha', '2026-06-10T11:59:00Z'),
|
||||||
|
], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes older telemetry reports with missing metric and container fields', () => {
|
||||||
|
const normalized = normalizeFleetNode({
|
||||||
|
node_id: 'legacy-node',
|
||||||
|
version: '1.8-alpha',
|
||||||
|
reported_at: '2026-06-10T11:59:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(normalized.node_id).toBe('legacy-node')
|
||||||
|
expect(normalized.cpu_pct).toBe(0)
|
||||||
|
expect(normalized.mem_pct).toBe(0)
|
||||||
|
expect(normalized.disk_pct).toBe(0)
|
||||||
|
expect(normalized.containers).toEqual([])
|
||||||
|
expect(normalized.recent_alerts).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes node history responses from backend entries or legacy history fields', () => {
|
||||||
|
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
|
||||||
|
|
||||||
|
expect(normalizeNodeHistoryResponse({ entries: [entry] })).toEqual([entry])
|
||||||
|
expect(normalizeNodeHistoryResponse({ history: [entry] })).toEqual([entry])
|
||||||
|
expect(normalizeNodeHistoryResponse({})).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -118,6 +118,58 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
|||||||
{ label: 'Name', value: 'name' },
|
{ label: 'Name', value: 'name' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] {
|
||||||
|
const sorted = [...nodes]
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'status':
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const aOnline = isOnline(a.reported_at)
|
||||||
|
const bOnline = isOnline(b.reported_at)
|
||||||
|
if (aOnline !== bOnline) return aOnline ? -1 : 1
|
||||||
|
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'last-seen':
|
||||||
|
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrZero(value: unknown): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
|
||||||
|
return {
|
||||||
|
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
|
||||||
|
version: typeof node.version === 'string' ? node.version : 'unknown',
|
||||||
|
uptime_secs: numberOrZero(node.uptime_secs),
|
||||||
|
cpu_cores: numberOrZero(node.cpu_cores),
|
||||||
|
cpu_pct: numberOrZero(node.cpu_pct),
|
||||||
|
mem_pct: numberOrZero(node.mem_pct),
|
||||||
|
disk_pct: numberOrZero(node.disk_pct),
|
||||||
|
container_count: numberOrZero(node.container_count),
|
||||||
|
running_count: numberOrZero(node.running_count),
|
||||||
|
federation_peers: numberOrZero(node.federation_peers),
|
||||||
|
recent_alerts: Array.isArray(node.recent_alerts) ? node.recent_alerts : [],
|
||||||
|
containers: Array.isArray(node.containers) ? node.containers : [],
|
||||||
|
reported_at: typeof node.reported_at === 'string' ? node.reported_at : new Date(0).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNodeHistoryResponse(data: {
|
||||||
|
history?: NodeHistoryEntry[]
|
||||||
|
entries?: NodeHistoryEntry[]
|
||||||
|
} | null | undefined): NodeHistoryEntry[] {
|
||||||
|
if (Array.isArray(data?.history)) return data.history
|
||||||
|
if (Array.isArray(data?.entries)) return data.entries
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
// --- Composable ---
|
// --- Composable ---
|
||||||
|
|
||||||
export function useFleetData() {
|
export function useFleetData() {
|
||||||
@ -125,6 +177,7 @@ export function useFleetData() {
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const nodes = ref<FleetNode[]>([])
|
const nodes = ref<FleetNode[]>([])
|
||||||
const fleetAlerts = ref<FleetAlert[]>([])
|
const fleetAlerts = ref<FleetAlert[]>([])
|
||||||
|
const refreshing = ref(false)
|
||||||
const alertsLoading = ref(false)
|
const alertsLoading = ref(false)
|
||||||
const selectedNodeId = ref<string | null>(null)
|
const selectedNodeId = ref<string | null>(null)
|
||||||
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
||||||
@ -166,26 +219,7 @@ export function useFleetData() {
|
|||||||
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
|
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedNodes = computed(() => {
|
const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
|
||||||
const sorted = [...nodes.value]
|
|
||||||
switch (sortBy.value) {
|
|
||||||
case 'status':
|
|
||||||
sorted.sort((a, b) => {
|
|
||||||
const aOnline = isOnline(a.reported_at)
|
|
||||||
const bOnline = isOnline(b.reported_at)
|
|
||||||
if (aOnline !== bOnline) return aOnline ? 1 : -1
|
|
||||||
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case 'last-seen':
|
|
||||||
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
|
||||||
break
|
|
||||||
case 'name':
|
|
||||||
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return sorted
|
|
||||||
})
|
|
||||||
|
|
||||||
const allAppIds = computed(() => {
|
const allAppIds = computed(() => {
|
||||||
const appSet = new Set<string>()
|
const appSet = new Set<string>()
|
||||||
@ -227,11 +261,11 @@ export function useFleetData() {
|
|||||||
|
|
||||||
async function fetchFleetStatus() {
|
async function fetchFleetStatus() {
|
||||||
try {
|
try {
|
||||||
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
|
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||||
method: 'telemetry.fleet-status',
|
method: 'telemetry.fleet-status',
|
||||||
})
|
})
|
||||||
if (data?.nodes) {
|
if (data?.nodes) {
|
||||||
nodes.value = data.nodes
|
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||||
lastRefreshed.value = new Date().toISOString()
|
lastRefreshed.value = new Date().toISOString()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -259,15 +293,12 @@ export function useFleetData() {
|
|||||||
|
|
||||||
async function fetchNodeHistory(nodeId: string) {
|
async function fetchNodeHistory(nodeId: string) {
|
||||||
nodeHistoryLoading.value = true
|
nodeHistoryLoading.value = true
|
||||||
nodeHistory.value = []
|
|
||||||
try {
|
try {
|
||||||
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
|
const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
|
||||||
method: 'telemetry.fleet-node-history',
|
method: 'telemetry.fleet-node-history',
|
||||||
params: { node_id: nodeId },
|
params: { node_id: nodeId },
|
||||||
})
|
})
|
||||||
if (data?.history) {
|
nodeHistory.value = normalizeNodeHistoryResponse(data)
|
||||||
nodeHistory.value = data.history
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
} finally {
|
} finally {
|
||||||
@ -277,9 +308,14 @@ export function useFleetData() {
|
|||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
loading.value = !nodes.value.length
|
loading.value = !nodes.value.length
|
||||||
|
refreshing.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
try {
|
||||||
loading.value = false
|
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNode(nodeId: string) {
|
function selectNode(nodeId: string) {
|
||||||
@ -288,7 +324,6 @@ export function useFleetData() {
|
|||||||
nodeHistory.value = []
|
nodeHistory.value = []
|
||||||
} else {
|
} else {
|
||||||
selectedNodeId.value = nodeId
|
selectedNodeId.value = nodeId
|
||||||
fetchNodeHistory(nodeId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,7 +402,7 @@ export function useFleetData() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading, errorMessage, nodes, fleetAlerts, alertsLoading,
|
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||||
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
||||||
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
||||||
onlineCount, offlineCount, healthyCount, fleetHealthPct,
|
onlineCount, offlineCount, healthyCount, fleetHealthPct,
|
||||||
|
|||||||
39
neode-ui/src/views/goals/__tests__/goalStepActions.test.ts
Normal file
39
neode-ui/src/views/goals/__tests__/goalStepActions.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { GOALS } from '@/data/goals'
|
||||||
|
import { goalStepTargetPath } from '../goalStepActions'
|
||||||
|
import type { GoalStep } from '@/types/goals'
|
||||||
|
|
||||||
|
describe('goalStepActions', () => {
|
||||||
|
it('routes app-backed steps to their app details page', () => {
|
||||||
|
expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes built-in identity and backup steps to their owning screens', () => {
|
||||||
|
expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
|
||||||
|
expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
|
||||||
|
expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps passive info steps without a target route', () => {
|
||||||
|
expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives every configure step in the shipped goals a destination', () => {
|
||||||
|
const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
|
||||||
|
|
||||||
|
expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
|
||||||
|
configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function step(overrides: Partial<GoalStep>): GoalStep {
|
||||||
|
return {
|
||||||
|
id: 'step',
|
||||||
|
title: 'Step',
|
||||||
|
description: '',
|
||||||
|
action: 'configure',
|
||||||
|
isAutomatic: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
neode-ui/src/views/goals/goalStepActions.ts
Normal file
14
neode-ui/src/views/goals/goalStepActions.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { GoalStep } from '@/types/goals'
|
||||||
|
|
||||||
|
const STEP_ROUTE_OVERRIDES: Record<string, string> = {
|
||||||
|
'setup-nostr': '/dashboard/web5',
|
||||||
|
'export-identity': '/dashboard/web5/credentials',
|
||||||
|
'create-passphrase': '/dashboard/settings',
|
||||||
|
'create-backup': '/dashboard/settings',
|
||||||
|
'save-backup': '/dashboard/settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goalStepTargetPath(step: GoalStep): string | null {
|
||||||
|
if (step.appId) return `/dashboard/apps/${step.appId}`
|
||||||
|
return STEP_ROUTE_OVERRIDES[step.id] ?? null
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user