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('') }
|
||||
|
||||
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)
|
||||
let userState = {
|
||||
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)
|
||||
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({
|
||||
id: 'bitcoin',
|
||||
title: 'Bitcoin Core',
|
||||
@ -765,6 +816,16 @@ const staticDevApps = {
|
||||
state: 'running',
|
||||
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({
|
||||
id: 'filebrowser',
|
||||
title: 'File Browser',
|
||||
@ -826,6 +887,200 @@ app.options('/rpc/v1', (req, res) => {
|
||||
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
|
||||
app.post('/rpc/v1', (req, res) => {
|
||||
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 { useUIModeStore } from '@/stores/uiMode'
|
||||
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
|
||||
import { shouldShowIntroSplash } from '@/utils/introSplash'
|
||||
|
||||
const router = useRouter()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
@ -176,6 +177,129 @@ const route = useRoute()
|
||||
// Start with splash hidden — onMounted decides whether to show it
|
||||
const showSplash = 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
|
||||
@ -213,18 +337,51 @@ onMounted(async () => {
|
||||
window.addEventListener('keydown', onUserActivity)
|
||||
window.addEventListener('touchstart', onUserActivity)
|
||||
window.addEventListener('message', onShareToMeshMessage)
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
|
||||
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'
|
||||
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)
|
||||
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 {
|
||||
// Already seen intro, direct route, or boot mode (boot screen handles intro)
|
||||
// Set isReady BEFORE hiding splash to prevent flash of partial content
|
||||
@ -243,6 +400,12 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onUserActivity)
|
||||
window.removeEventListener('touchstart', onUserActivity)
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.startPackage('btc')
|
||||
|
||||
@ -201,7 +201,7 @@ class RPCClient {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
alsoChangeSsh?: boolean
|
||||
}): Promise<{ success: boolean }> {
|
||||
}): Promise<{ success: boolean; ssh_updated?: boolean; ssh_error?: string | null }> {
|
||||
return this.call({
|
||||
method: 'auth.changePassword',
|
||||
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
|
||||
// flipping state. Graceful stop (up to 600s for bitcoin) and data wipe
|
||||
// (up to minutes for large chainstate) run in a background task.
|
||||
// Progress shown via uninstall_stage field on the package entry.
|
||||
return this.call({
|
||||
method: 'package.uninstall',
|
||||
params: { id },
|
||||
params: { id, ...(options.preserveData !== undefined ? { preserve_data: options.preserveData } : {}) },
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show: boolean
|
||||
@ -65,6 +66,7 @@ function close() {
|
||||
}
|
||||
|
||||
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||
useBodyScrollLock(computed(() => props.show))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -11,10 +11,18 @@
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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="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" />
|
||||
@ -22,38 +30,44 @@
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
|
||||
Control your node from another device. Install the
|
||||
<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 class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-4 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"
|
||||
@click="dismiss"
|
||||
<div class="mb-4">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
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
|
||||
</button>
|
||||
Download companion app
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -61,11 +75,15 @@
|
||||
</template>
|
||||
|
||||
<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 DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
|
||||
|
||||
const visible = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||
|
||||
onMounted(() => {
|
||||
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() {
|
||||
visible.value = false
|
||||
try {
|
||||
|
||||
@ -20,6 +20,7 @@ const sharingLocation = ref(false)
|
||||
const locationSource = ref<'browser' | 'device'>('browser')
|
||||
const locationError = ref('')
|
||||
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
|
||||
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
|
||||
let geoWatchId: number | null = null
|
||||
|
||||
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" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</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>
|
||||
|
||||
<!-- Location sharing overlay -->
|
||||
@ -373,7 +374,9 @@ onUnmounted(() => {
|
||||
>Mesh Radio GPS</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<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>
|
||||
|
||||
@ -67,6 +68,7 @@ import { ref, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -124,6 +126,9 @@ async function receive() {
|
||||
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
|
||||
} else if (receiveMethod.value === 'onchain') {
|
||||
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
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else {
|
||||
@ -136,7 +141,9 @@ async function receive() {
|
||||
emit('received')
|
||||
}
|
||||
} 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 {
|
||||
processing.value = false
|
||||
}
|
||||
|
||||
@ -93,14 +93,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
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()
|
||||
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')
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
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
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
screenshots?: AppScreenshot[]
|
||||
containerConfig?: {
|
||||
ports?: 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
|
||||
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
||||
|
||||
@ -46,6 +52,7 @@ export function useMarketplaceApp() {
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
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'],
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'configure-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.',
|
||||
appId: 'btcpay-server',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
@ -72,6 +73,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'open-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.',
|
||||
appId: 'lnd',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
@ -99,6 +101,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'configure-filebrowser',
|
||||
title: 'Log In',
|
||||
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
|
||||
appId: 'filebrowser',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
@ -126,6 +129,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'configure-nextcloud',
|
||||
title: 'Set Up Your Cloud',
|
||||
description: 'Create your admin account and configure storage. Nextcloud will open for you to complete setup.',
|
||||
appId: 'nextcloud',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
@ -168,6 +172,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'open-channels',
|
||||
title: 'Open Payment Channels',
|
||||
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
||||
appId: 'lnd',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
@ -210,6 +215,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'configure-guardian',
|
||||
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.',
|
||||
appId: 'fedimint',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
|
||||
@ -112,10 +112,12 @@
|
||||
"setupTab": "Setup",
|
||||
"myApps": "My Apps",
|
||||
"myAppsDesc": "Manage your installed applications",
|
||||
"recommendedApps": "Recommended Apps",
|
||||
"recommendedAppsDesc": "Add useful App Store apps to your Home screen",
|
||||
"cloud": "Cloud",
|
||||
"cloudDesc": "Cloud services and storage",
|
||||
"network": "Network",
|
||||
"networkDesc": "Network infrastructure and Web3 services",
|
||||
"networkDesc": "Network infrastructure and mesh services",
|
||||
"web5": "Web5",
|
||||
"web5Desc": "Decentralized identity and data protocols",
|
||||
"system": "System",
|
||||
@ -147,6 +149,7 @@
|
||||
"updateAvailable": "Update Available: v{version}",
|
||||
"updateNow": "Update Now",
|
||||
"goToApps": "Go to Apps",
|
||||
"goToAppStore": "Go to App Store",
|
||||
"goToCloud": "Go to Cloud",
|
||||
"goToNetwork": "Go to Network",
|
||||
"goToWeb5": "Go to Web5",
|
||||
@ -162,6 +165,8 @@
|
||||
"noResults": "No apps matching \"{query}\"",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"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",
|
||||
"searchLabel": "Search installed apps"
|
||||
},
|
||||
@ -172,7 +177,7 @@
|
||||
"interfaceMode": "Interface Mode",
|
||||
"claudeAuth": "Claude Authentication",
|
||||
"aiDataAccess": "AI Data Access",
|
||||
"serverName": "Server Name",
|
||||
"serverName": "Hostname",
|
||||
"sessionStatus": "Session Status",
|
||||
"yourDid": "Your DID",
|
||||
"onionAddress": "Node .onion Address",
|
||||
@ -301,7 +306,8 @@
|
||||
"webhookSendFailed": "Failed to send test webhook",
|
||||
"passwordAllFieldsRequired": "All fields are required",
|
||||
"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",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
||||
@ -520,6 +526,10 @@
|
||||
"registerNewName": "Register New Name",
|
||||
"verifyNip05": "Verify NIP-05",
|
||||
"peers": "Peers",
|
||||
"trusted": "Trusted",
|
||||
"observer": "Observer",
|
||||
"observers": "Observers",
|
||||
"noObservers": "No observers yet.",
|
||||
"messages": "Messages",
|
||||
"requests": "Requests",
|
||||
"myContent": "My Content",
|
||||
@ -538,6 +548,7 @@
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"setupInstructions": "Setup Instructions",
|
||||
"ram": "RAM",
|
||||
"ramDesc": "Minimum 512MB",
|
||||
"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.",
|
||||
"installApp": "Install {name}",
|
||||
"openAndConfigure": "Open & Configure",
|
||||
"checkAndContinue": "Check & Continue",
|
||||
"iveDoneThis": "I've Done This",
|
||||
"complete": "Complete",
|
||||
"allSet": "All Set!",
|
||||
|
||||
@ -112,10 +112,12 @@
|
||||
"setupTab": "Configuraci\u00f3n",
|
||||
"myApps": "Mis aplicaciones",
|
||||
"myAppsDesc": "Administre sus aplicaciones instaladas",
|
||||
"recommendedApps": "Aplicaciones recomendadas",
|
||||
"recommendedAppsDesc": "Agregue aplicaciones utiles de la tienda a su pantalla de inicio",
|
||||
"cloud": "Nube",
|
||||
"cloudDesc": "Servicios en la nube y almacenamiento",
|
||||
"network": "Red",
|
||||
"networkDesc": "Infraestructura de red y servicios Web3",
|
||||
"networkDesc": "Infraestructura de red y servicios mesh",
|
||||
"web5": "Web5",
|
||||
"web5Desc": "Identidad descentralizada y protocolos de datos",
|
||||
"system": "Sistema",
|
||||
@ -147,6 +149,7 @@
|
||||
"updateAvailable": "Actualizaci\u00f3n disponible: v{version}",
|
||||
"updateNow": "Actualizar ahora",
|
||||
"goToApps": "Ir a aplicaciones",
|
||||
"goToAppStore": "Ir a la tienda de aplicaciones",
|
||||
"goToCloud": "Ir a la nube",
|
||||
"goToNetwork": "Ir a la red",
|
||||
"goToWeb5": "Ir a Web5",
|
||||
@ -162,6 +165,8 @@
|
||||
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
||||
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
||||
"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",
|
||||
"searchLabel": "Buscar aplicaciones instaladas"
|
||||
},
|
||||
@ -172,7 +177,7 @@
|
||||
"interfaceMode": "Modo de interfaz",
|
||||
"claudeAuth": "Autenticaci\u00f3n de Claude",
|
||||
"aiDataAccess": "Acceso a datos de IA",
|
||||
"serverName": "Nombre del servidor",
|
||||
"serverName": "Hostname",
|
||||
"sessionStatus": "Estado de la sesi\u00f3n",
|
||||
"yourDid": "Su DID",
|
||||
"onionAddress": "Direcci\u00f3n .onion del nodo",
|
||||
@ -301,7 +306,8 @@
|
||||
"webhookSendFailed": "Error al enviar el webhook de prueba",
|
||||
"passwordAllFieldsRequired": "Todos los campos son obligatorios",
|
||||
"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",
|
||||
"passwordMinLength": "La contrase\u00f1a debe tener al menos 12 caracteres",
|
||||
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
|
||||
@ -520,6 +526,10 @@
|
||||
"registerNewName": "Registrar nuevo nombre",
|
||||
"verifyNip05": "Verificar NIP-05",
|
||||
"peers": "Pares",
|
||||
"trusted": "Confiables",
|
||||
"observer": "Observador",
|
||||
"observers": "Observadores",
|
||||
"noObservers": "A\u00fan no hay observadores.",
|
||||
"messages": "Mensajes",
|
||||
"requests": "Solicitudes",
|
||||
"myContent": "Mi contenido",
|
||||
@ -538,6 +548,7 @@
|
||||
"features": "Caracter\u00edsticas",
|
||||
"information": "Informaci\u00f3n",
|
||||
"requirements": "Requisitos",
|
||||
"setupInstructions": "Instrucciones de configuraci\u00f3n",
|
||||
"ram": "RAM",
|
||||
"ramDesc": "M\u00ednimo 512MB",
|
||||
"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.",
|
||||
"installApp": "Instalar {name}",
|
||||
"openAndConfigure": "Abrir y configurar",
|
||||
"checkAndContinue": "Verificar y continuar",
|
||||
"iveDoneThis": "Ya lo hice",
|
||||
"complete": "Completar",
|
||||
"allSet": "\u00a1Todo listo!",
|
||||
|
||||
@ -12,7 +12,7 @@ vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: mockPush },
|
||||
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
@ -66,7 +66,7 @@ describe('useAppLauncherStore', () => {
|
||||
store.openSession('indeedhub')
|
||||
|
||||
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', () => {
|
||||
@ -95,7 +95,12 @@ describe('useAppLauncherStore', () => {
|
||||
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
|
||||
|
||||
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', () => {
|
||||
@ -125,7 +130,7 @@ describe('useAppLauncherStore', () => {
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
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', () => {
|
||||
@ -186,7 +191,12 @@ describe('useAppLauncherStore', () => {
|
||||
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
|
||||
|
||||
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', () => {
|
||||
@ -195,7 +205,12 @@ describe('useAppLauncherStore', () => {
|
||||
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
|
||||
|
||||
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', () => {
|
||||
@ -261,7 +276,7 @@ describe('useAppLauncherStore', () => {
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
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', () => {
|
||||
|
||||
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
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) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
@ -102,8 +103,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'9010': 'saleor',
|
||||
'9011': 'saleor',
|
||||
'8087': 'netbird',
|
||||
'8086': 'netbird',
|
||||
'9980': 'onlyoffice',
|
||||
@ -186,21 +185,24 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function dashboardReturnPath(): string {
|
||||
const current = router.currentRoute.value
|
||||
const current = router.currentRoute?.value
|
||||
if (!current) return '/dashboard/apps'
|
||||
const fullPath = current.fullPath || '/dashboard/apps'
|
||||
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
|
||||
return fullPath
|
||||
}
|
||||
|
||||
function openSession(appId: string) {
|
||||
recordAppLaunch(appId)
|
||||
const mobile = isMobileViewport()
|
||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||
if (launchUrl) {
|
||||
if (launchUrl && !mobile) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mode === 'panel' && !isMobileViewport()) {
|
||||
if (mode === 'panel' && !mobile) {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
@ -219,6 +221,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
if (resolvedId) recordAppLaunch(resolvedId)
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
@ -227,6 +230,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// phones, route through the app session/webview so app icons behave like
|
||||
// native launchers and keep the user inside Archipelago.
|
||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
recordAppLaunch(resolvedId)
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; stat
|
||||
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
||||
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
||||
'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' },
|
||||
'done': { progress: 100, message: 'Installed', status: 'complete' },
|
||||
}
|
||||
@ -126,6 +126,12 @@ export const useServerStore = defineStore('server', () => {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
async function uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
|
||||
return rpcClient.uninstallPackage(id)
|
||||
async function uninstallPackage(
|
||||
id: string,
|
||||
options: { preserveData?: boolean } = {},
|
||||
): Promise<{ status: string; package_id: string }> {
|
||||
return rpcClient.uninstallPackage(id, options)
|
||||
}
|
||||
|
||||
async function startPackage(id: string): Promise<void> {
|
||||
|
||||
@ -80,6 +80,50 @@ select:focus-visible {
|
||||
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 */
|
||||
@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"]) {
|
||||
@ -140,7 +184,7 @@ input[type="radio"]:active + * {
|
||||
[data-controller-container]:focus-visible,
|
||||
[data-controller-container]:focus {
|
||||
outline: none;
|
||||
transform: translateY(-4px) scale(1.01) translateZ(0);
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
box-shadow:
|
||||
0 0 6px 2px rgba(251, 146, 60, 0.35),
|
||||
0 0 20px rgba(251, 146, 60, 0.15),
|
||||
@ -178,16 +222,18 @@ input[type="radio"]:active + * {
|
||||
border-radius: 1rem;
|
||||
overflow-x: hidden;
|
||||
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
|
||||
viewport. Chromium/Brave can corrupt those layers into black rectangles,
|
||||
so keep the translucency but avoid per-card backdrop compositor layers. */
|
||||
/* Dashboard content lives inside animated perspective/scroll containers.
|
||||
Chromium/Brave can corrupt backdrop-filter + transformed cards into black
|
||||
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,
|
||||
.apps-view .mode-switcher,
|
||||
@ -209,6 +255,84 @@ input[type="radio"]:active + * {
|
||||
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) */
|
||||
.mode-switcher-full {
|
||||
display: flex;
|
||||
@ -1305,6 +1429,32 @@ html:has(body.video-background-active)::before {
|
||||
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) */
|
||||
@keyframes bg-glitch-shift-repeat {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: none;
|
||||
}
|
||||
@ -2085,11 +2245,9 @@ html:has(body.video-background-active)::before {
|
||||
@keyframes card-stagger-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2139,14 +2297,27 @@ html:has(body.video-background-active)::before {
|
||||
height: 60px;
|
||||
border-radius: 18px;
|
||||
overflow: visible;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
@ -2202,6 +2373,19 @@ html:has(body.video-background-active)::before {
|
||||
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 */
|
||||
.app-icon-dots {
|
||||
display: flex;
|
||||
@ -2237,10 +2421,15 @@ html:has(body.video-background-active)::before {
|
||||
|
||||
/* Monitoring dashboard */
|
||||
.monitoring-stat-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.monitoring-stat-card-compact {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.monitoring-chart {
|
||||
@ -2370,10 +2559,12 @@ html:has(body.video-background-active)::before {
|
||||
}
|
||||
|
||||
.fleet-sort-btn {
|
||||
padding: 4px 10px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@ -87,6 +87,7 @@ export interface PackageDataEntry {
|
||||
license: string
|
||||
instructions: string
|
||||
icon: string
|
||||
screenshots?: AppScreenshot[]
|
||||
}
|
||||
manifest: Manifest
|
||||
installed?: InstalledPackageDataEntry
|
||||
@ -121,6 +122,12 @@ export interface Manifest {
|
||||
'lan-config'?: string
|
||||
}
|
||||
}
|
||||
screenshots?: AppScreenshot[]
|
||||
}
|
||||
|
||||
export type AppScreenshot = string | {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
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"
|
||||
:can-launch="canLaunch"
|
||||
:is-web-only="isWebOnly"
|
||||
:pending-action="pendingAction"
|
||||
@launch="launchApp"
|
||||
@start="startApp"
|
||||
@stop="stopApp"
|
||||
@ -57,6 +58,7 @@
|
||||
:tor-url="torUrl"
|
||||
:show-tor-address="showTorAddress"
|
||||
:credentials="credentials"
|
||||
:credentials-loading="credentialsLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -70,52 +72,13 @@
|
||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="uninstallModal.show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@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>
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="pendingAction === 'uninstall'"
|
||||
@close="closeUninstallModal"
|
||||
@confirm="confirmUninstall"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
@ -135,14 +98,15 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
||||
import {
|
||||
WEB_ONLY_APP_URLS,
|
||||
@ -217,6 +181,8 @@ const bitcoinSyncPercent = ref(0)
|
||||
const bitcoinBlockHeight = ref(0)
|
||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||
const credentials = ref<AppCredentialsResponse | null>(null)
|
||||
const credentialsLoading = ref(false)
|
||||
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||
|
||||
async function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
@ -235,15 +201,18 @@ async function loadBitcoinSync() {
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
credentialsLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: packageKey.value },
|
||||
timeout: 5000,
|
||||
})
|
||||
credentials.value = result.credentials?.length ? result : null
|
||||
credentials.value = resolveAppCredentials(packageKey.value, result)
|
||||
} 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 uninstallModalRef = ref<HTMLElement | null>(null)
|
||||
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeUninstallModal() {
|
||||
uninstallRestoreFocusRef.value?.focus?.()
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
useModalKeyboard(
|
||||
uninstallModalRef,
|
||||
computed(() => uninstallModal.value.show),
|
||||
closeUninstallModal,
|
||||
{ restoreFocusRef: uninstallRestoreFocusRef }
|
||||
)
|
||||
|
||||
const backButtonText = computed(() => {
|
||||
if (route.query.from === 'discover') return 'Back to Discover'
|
||||
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
||||
@ -300,7 +259,15 @@ const features = computed(() => [
|
||||
])
|
||||
|
||||
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() {
|
||||
@ -335,34 +302,46 @@ function launchApp() {
|
||||
|
||||
|
||||
async function startApp() {
|
||||
pendingAction.value = 'start'
|
||||
try {
|
||||
await store.startPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp() {
|
||||
pendingAction.value = 'stop'
|
||||
try {
|
||||
await store.stopPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
pendingAction.value = 'restart'
|
||||
try {
|
||||
await store.restartPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateApp() {
|
||||
pendingAction.value = 'update'
|
||||
try {
|
||||
await store.updatePackage(appId.value)
|
||||
} catch (err) {
|
||||
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 }
|
||||
}
|
||||
|
||||
async function confirmUninstall() {
|
||||
async function confirmUninstall(deleteAppData: boolean) {
|
||||
uninstallModal.value.show = false
|
||||
pendingAction.value = 'uninstall'
|
||||
try {
|
||||
await store.uninstallPackage(appId.value)
|
||||
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -131,7 +131,7 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
|
||||
@ -32,6 +32,8 @@
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@ -101,6 +103,7 @@ import {
|
||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||
resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
|
||||
@ -147,6 +150,9 @@ const appId = computed(() => {
|
||||
})
|
||||
|
||||
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 mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
@ -344,8 +350,9 @@ watch(displayMode, (mode) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes open externally instead of landing in a broken webview.
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
// Apps that block iframes open externally on desktop. On mobile, keep the
|
||||
// session surface visible so launcher taps do not bounce straight out.
|
||||
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
@ -355,7 +362,7 @@ onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
} else {
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
<div class="mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search -->
|
||||
<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>
|
||||
<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>
|
||||
</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
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@ -18,7 +18,7 @@
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@ -88,6 +88,18 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@ -134,6 +146,17 @@
|
||||
</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 -->
|
||||
<div class="md:hidden">
|
||||
<AppIconGrid
|
||||
@ -176,7 +199,7 @@
|
||||
<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"
|
||||
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">
|
||||
@ -290,7 +313,10 @@ import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry,
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { useLastKnownPackages } from './apps/appPackageCache'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import { validateSideloadRequest } from './apps/sideloadValidation'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
@ -351,9 +377,16 @@ const selectedCategory = ref('all')
|
||||
|
||||
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
|
||||
const packages = computed(() => {
|
||||
const realPackages = store.packages || {}
|
||||
const realPackages = stablePackages.value
|
||||
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
|
||||
|
||||
// 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 isCheckingContainers = computed(() => (
|
||||
store.hasLoadedInitialData &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
!isUsingLastKnownPackages.value &&
|
||||
sortedPackageEntries.value.length === 0 &&
|
||||
!searchQuery.value &&
|
||||
!containersScanned.value
|
||||
))
|
||||
|
||||
// Connection error state
|
||||
const connectionError = ref('')
|
||||
@ -457,13 +498,13 @@ function closeUninstallModal() {
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
async function onConfirmUninstall() {
|
||||
async function onConfirmUninstall(deleteAppData: boolean) {
|
||||
const { appId } = uninstallModal.value
|
||||
// Close the modal immediately so the user can fire off concurrent
|
||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||
// its uninstall is in flight.
|
||||
uninstallModal.value.show = false
|
||||
await actions.confirmUninstall(appId)
|
||||
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
|
||||
}
|
||||
|
||||
function goToApp(id: string) {
|
||||
@ -508,18 +549,29 @@ async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
params: { app_id: id },
|
||||
timeout: 5000,
|
||||
})
|
||||
if (!result.credentials?.length) return false
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: result.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: result.credentials,
|
||||
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
|
||||
} 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.'
|
||||
return
|
||||
}
|
||||
const validationError = validateSideloadRequest(id, port, store.packages)
|
||||
if (validationError) {
|
||||
sideloadError.value = validationError
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
@ -693,6 +750,10 @@ async function submitSideload() {
|
||||
.credential-modal {
|
||||
display: flex;
|
||||
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 {
|
||||
min-height: 0;
|
||||
|
||||
@ -83,6 +83,17 @@
|
||||
</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) -->
|
||||
<div
|
||||
v-if="!peersLoading && peerNodes.length === 0"
|
||||
@ -263,12 +274,13 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function loadPeers() {
|
||||
const hadPeers = peerNodes.value.length > 0
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
peerNodes.value = result?.nodes ?? []
|
||||
} catch (e) {
|
||||
peerNodes.value = []
|
||||
if (!hadPeers) peerNodes.value = []
|
||||
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
@ -283,4 +295,6 @@ function peerDisplayName(did: string): string {
|
||||
function openSection(section: ContentSection) {
|
||||
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
||||
}
|
||||
|
||||
defineExpose({ loadPeers })
|
||||
</script>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<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" />
|
||||
</svg>
|
||||
Back to Cloud
|
||||
{{ backLabel }}
|
||||
</button>
|
||||
|
||||
<!-- 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Cloud</span>
|
||||
<span>{{ backLabel }}</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
@ -106,7 +106,7 @@
|
||||
<CloudToolbar
|
||||
:breadcrumbs="cloudStore.breadcrumbs"
|
||||
:view-mode="viewMode"
|
||||
@navigate="cloudStore.navigate($event)"
|
||||
@navigate="navigateCloudPath"
|
||||
@refresh="cloudStore.refresh()"
|
||||
@upload="handleUpload"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@ -115,7 +115,7 @@
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="cloudStore.navigate($event)"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@ -178,6 +178,7 @@ import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { normalizeCloudPath, parentCloudPath } from './cloudPath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -189,6 +190,7 @@ const audioPlayer = useAudioPlayer()
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
@ -286,14 +288,20 @@ const section = computed(() => {
|
||||
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||
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
|
||||
watch([useNativeUI, section], async ([native, sec]) => {
|
||||
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||
if (native && sec) {
|
||||
cloudStore.reset()
|
||||
if (cloudStore.currentPath !== path) {
|
||||
cloudStore.reset()
|
||||
}
|
||||
const ok = await cloudStore.init()
|
||||
if (ok) {
|
||||
await cloudStore.navigate(sec.initialPath)
|
||||
await cloudStore.navigate(path)
|
||||
}
|
||||
}
|
||||
}, { 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() {
|
||||
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
|
||||
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -63,6 +63,13 @@
|
||||
No credentials yet. Issue one above or receive one from a peer.
|
||||
</div>
|
||||
<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
|
||||
v-for="cred in credentials"
|
||||
:key="cred.id"
|
||||
@ -115,7 +122,7 @@
|
||||
<!-- Credential Detail Modal -->
|
||||
<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 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="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
||||
@ -403,6 +410,8 @@ async function copyCredentialJson() {
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadIdentities(), loadCredentials()])
|
||||
})
|
||||
|
||||
defineExpose({ credentials, loadCredentials })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
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 }"
|
||||
class="mobile-safe-top"
|
||||
>
|
||||
@ -94,7 +94,7 @@
|
||||
<div
|
||||
v-else
|
||||
: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
|
||||
? 'mobile-scroll-pad-back'
|
||||
: 'mobile-scroll-pad'
|
||||
|
||||
@ -3,25 +3,50 @@
|
||||
<!-- Navigation Bar (always at top) -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div class="hidden md:flex mb-6 items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</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 ref="discoverHeaderRef" class="hidden md:flex mb-6 items-center gap-4 relative">
|
||||
<div ref="discoverPrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</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 class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
||||
>Discover</RouterLink>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="navigateToMarketplace(category.id)"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
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>
|
||||
</div>
|
||||
<input
|
||||
@ -29,7 +54,7 @@
|
||||
type="text"
|
||||
placeholder="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>
|
||||
|
||||
@ -40,14 +65,14 @@
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<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
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="navigateToMarketplace(category.id)"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@ -193,6 +218,8 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
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 FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
@ -211,19 +238,18 @@ const selectedCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
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(() => [
|
||||
{ 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' }
|
||||
])
|
||||
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation.
|
||||
// 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 } })
|
||||
}
|
||||
|
||||
function selectDiscoverCategory(categoryId: string) {
|
||||
if (categoryId === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
navigateToMarketplace(categoryId)
|
||||
}
|
||||
|
||||
// Community & Nostr marketplace state
|
||||
const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
@ -303,14 +337,6 @@ const allApps = computed(() => {
|
||||
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(() => {
|
||||
let apps = allApps.value
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
|
||||
@ -371,14 +371,23 @@ function isOnlineCheck(node: FederatedNode): boolean {
|
||||
}
|
||||
|
||||
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 {
|
||||
loading.value = true
|
||||
if (showLoader) loading.value = true
|
||||
const result = await rpcClient.federationListNodes()
|
||||
nodes.value = result.nodes
|
||||
error.value = ''
|
||||
} 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 {
|
||||
loading.value = false
|
||||
if (showLoader) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -540,7 +549,7 @@ async function rotateDid(password: string) {
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadNodes()
|
||||
loadNodesWithOptions({ showLoader: true })
|
||||
loadDwnStatus()
|
||||
loadDiscoveryState()
|
||||
loadPendingRequests()
|
||||
@ -552,7 +561,7 @@ onMounted(async () => {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
loadNodes()
|
||||
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
@ -22,18 +22,27 @@
|
||||
|
||||
<!-- Header -->
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span v-if="fleet.autoRefresh.value" class="text-xs text-white/40">Auto-refresh 60s</span>
|
||||
<div class="grid grid-cols-3 gap-2 min-[1440px]:flex min-[1440px]:items-center">
|
||||
<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">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">
|
||||
Refresh
|
||||
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
|
||||
Export JSON
|
||||
@ -46,15 +55,37 @@
|
||||
<div class="md:hidden mb-6">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<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>
|
||||
|
||||
<!-- Error State -->
|
||||
@ -76,6 +107,14 @@
|
||||
: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
|
||||
:nodes="fleet.nodes.value"
|
||||
:sorted-nodes="fleet.sortedNodes.value"
|
||||
|
||||
@ -111,6 +111,13 @@
|
||||
>
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
</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
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
@ -167,6 +174,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
@ -287,10 +295,7 @@ async function installApp(step: GoalStep) {
|
||||
if (!step.appId) return
|
||||
isInstalling.value = true
|
||||
|
||||
// Start goal tracking if not already started
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
ensureGoalStarted()
|
||||
|
||||
try {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
@ -303,19 +308,28 @@ async function installApp(step: GoalStep) {
|
||||
}
|
||||
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
// Mark step as complete when user acknowledges they've configured
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
// If there's an app to open, navigate to it
|
||||
if (step.appId) {
|
||||
router.push(`/dashboard/apps/${step.appId}`)
|
||||
const targetPath = goalStepTargetPath(step)
|
||||
if (targetPath) {
|
||||
router.push(targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
function completeVerifyStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function completeInfoStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function ensureGoalStarted() {
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
|
||||
@ -86,8 +86,8 @@
|
||||
</div>
|
||||
<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">
|
||||
<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 }">
|
||||
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
|
||||
<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="w-full h-full rounded-xl archy-app-icon" @error="handleImageError" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -125,8 +125,58 @@
|
||||
</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 -->
|
||||
<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-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">
|
||||
@ -179,6 +229,33 @@
|
||||
@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 -->
|
||||
<HomeSystemCard
|
||||
:animate="animateCards"
|
||||
@ -187,33 +264,6 @@
|
||||
:uptime-display="systemUptimeDisplay"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- Chat Mode -->
|
||||
@ -246,6 +296,11 @@ import { GOALS } from '@/data/goals'
|
||||
import EasyHome from '@/components/EasyHome.vue'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-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 HomeSystemCard from './home/HomeSystemCard.vue'
|
||||
import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
||||
@ -264,6 +319,7 @@ const QUICK_START_RESHOW_LOGINS = 5
|
||||
const store = useAppStore()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
|
||||
const LINE1 = t('home.title')
|
||||
const LINE2 = t('home.subtitle')
|
||||
@ -306,11 +362,40 @@ const packages = computed(() => store.packages)
|
||||
const appCount = computed(() => Object.keys(packages.value || {}).length)
|
||||
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
|
||||
|
||||
const quickLaunchApps = [
|
||||
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
|
||||
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
|
||||
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
|
||||
]
|
||||
const quickLaunchApps = computed(() => {
|
||||
const usage = getAppUsage()
|
||||
return Object.entries(packages.value || {})
|
||||
.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
|
||||
const torConnected = computed(() => {
|
||||
|
||||
@ -3,27 +3,51 @@
|
||||
<!-- Header Section -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div class="hidden md:flex mb-4 items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</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 ref="marketplaceHeaderRef" class="hidden md:flex mb-4 items-center gap-4 relative">
|
||||
<div ref="marketplacePrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</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 class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
||||
>Discover</RouterLink>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === section.id }"
|
||||
>
|
||||
{{ category.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>
|
||||
{{ section.name }}
|
||||
<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>
|
||||
</div>
|
||||
<input
|
||||
@ -31,7 +55,7 @@
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
: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>
|
||||
|
||||
@ -43,18 +67,13 @@
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<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-active': selectedCategory === section.id }"
|
||||
type="button"
|
||||
>Discover</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>
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@ -76,6 +95,26 @@
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<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
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
@ -97,15 +136,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0" class="text-center py-12">
|
||||
<div v-if="loadingCommunity || nostrLoading" 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">
|
||||
<div v-if="filteredApps.length === 0 && !(loadingCommunity || nostrLoading)" class="text-center py-12">
|
||||
<div v-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/40 text-sm">{{ nostrError }}</p>
|
||||
<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 { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
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 {
|
||||
type MarketplaceApp,
|
||||
@ -155,19 +189,8 @@ const toast = useToast()
|
||||
// Category state — read initial value from query param (set by Discover page navigation)
|
||||
const selectedCategory = ref((route.query.category as string) || 'all')
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ 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') }
|
||||
])
|
||||
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
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) => {
|
||||
const next = typeof category === 'string' && category ? category : 'all'
|
||||
selectedCategory.value = next
|
||||
@ -200,6 +231,15 @@ const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
const searchQuery = ref('')
|
||||
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
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
@ -270,14 +310,6 @@ const allApps = computed(() => {
|
||||
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(() => {
|
||||
let apps = allApps.value
|
||||
|
||||
@ -500,4 +532,49 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
scrollbar-width: thin;
|
||||
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>
|
||||
|
||||
@ -201,20 +201,18 @@
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- 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>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
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"
|
||||
>
|
||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
const features = computed(() => {
|
||||
return [
|
||||
|
||||
@ -15,10 +15,12 @@ const transport = useTransportStore()
|
||||
|
||||
// Responsive layout breakpoints
|
||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
|
||||
const isMobile = ref(window.innerWidth < 1280)
|
||||
|
||||
function handleResize() {
|
||||
isWideDesktop.value = window.innerWidth >= 1536
|
||||
isVeryWideDesktop.value = window.innerWidth >= 2560 && window.innerHeight >= 1200
|
||||
isMobile.value = window.innerWidth < 1280
|
||||
}
|
||||
|
||||
@ -251,15 +253,21 @@ const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
)
|
||||
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'
|
||||
})
|
||||
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'
|
||||
})
|
||||
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'
|
||||
})
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<!-- Device Status -->
|
||||
@ -1727,8 +1735,7 @@ function isImageMime(mime?: string): boolean {
|
||||
|
||||
<!-- Tools panels (3rd column on wide screens) -->
|
||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||
<!-- Tools tab bar (wide desktop only) -->
|
||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></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>
|
||||
</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 -->
|
||||
<button
|
||||
@click="selectOption('fresh')"
|
||||
@ -52,22 +52,6 @@
|
||||
Enter your 24-word recovery phrase
|
||||
</p>
|
||||
</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>
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
||||
</div>
|
||||
@ -67,12 +67,23 @@
|
||||
</div>
|
||||
|
||||
<!-- 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-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card overflow-hidden"
|
||||
>
|
||||
<div v-if="catalogItems.length > 0" 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 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) -->
|
||||
<div
|
||||
v-if="isMediaMime(item.mime_type)"
|
||||
@ -192,6 +203,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -313,9 +325,9 @@ onMounted(async () => {
|
||||
async function loadCatalog() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
const hadItems = catalogItems.value.length > 0
|
||||
loading.value = true
|
||||
catalogError.value = ''
|
||||
catalogItems.value = []
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
@ -325,6 +337,7 @@ async function loadCatalog() {
|
||||
catalogItems.value = result?.items ?? []
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
if (!hadItems) catalogItems.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -563,6 +576,8 @@ function triggerDownload(base64Data: string, item: CatalogItem) {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
defineExpose({ loadCatalog })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -80,6 +80,13 @@
|
||||
</template>
|
||||
|
||||
<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 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>
|
||||
@ -135,46 +142,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<!-- 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 />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 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 gap-3">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- WireGuard Status -->
|
||||
@ -221,10 +196,14 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</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>
|
||||
|
||||
<!-- 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>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||
@ -233,7 +212,7 @@
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@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
|
||||
</button>
|
||||
@ -246,6 +225,13 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<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
|
||||
v-for="iface in physicalInterfaces"
|
||||
: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>
|
||||
</div>
|
||||
</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><!-- close VPN+Network 2-col grid -->
|
||||
@ -413,6 +407,8 @@ const logCount = ref(0)
|
||||
|
||||
// Network data
|
||||
const networkLoading = ref(true)
|
||||
const networkRefreshing = ref(false)
|
||||
const networkHasLoaded = ref(false)
|
||||
const networkData = ref({
|
||||
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||
@ -449,7 +445,9 @@ async function loadFipsSummary() {
|
||||
}
|
||||
|
||||
async function loadNetworkData() {
|
||||
networkLoading.value = true
|
||||
const initialLoad = !networkHasLoaded.value
|
||||
networkLoading.value = initialLoad
|
||||
networkRefreshing.value = !initialLoad
|
||||
try {
|
||||
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' }),
|
||||
@ -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 (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 }
|
||||
} 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
|
||||
@ -548,6 +550,8 @@ interface NetworkInterface { name: string; type: string; state: string; mac: str
|
||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||
|
||||
const interfacesLoading = ref(true)
|
||||
const interfacesRefreshing = ref(false)
|
||||
const interfacesHaveLoaded = ref(false)
|
||||
const allInterfaces = ref<NetworkInterface[]>([])
|
||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || 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() {
|
||||
interfacesLoading.value = true
|
||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { allInterfaces.value = [] } finally { interfacesLoading.value = false }
|
||||
const initialLoad = !interfacesHaveLoaded.value
|
||||
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() {
|
||||
@ -670,9 +677,10 @@ const availableAppsForTor = computed(() => {
|
||||
})
|
||||
|
||||
async function loadTorServices() {
|
||||
const hadServices = torServices.value.length > 0
|
||||
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 }
|
||||
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) {
|
||||
@ -753,4 +761,16 @@ async function checkTorStatus() {
|
||||
|
||||
const logsToast = ref('')
|
||||
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>
|
||||
|
||||
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>
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- 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>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
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"
|
||||
>
|
||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Sync Warning (for dependent apps) -->
|
||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-5 border border-orange-500/30">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<svg class="w-6 h-6 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-6 border border-orange-500/30">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<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" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-orange-300 font-semibold text-sm">Bitcoin is syncing</p>
|
||||
<p class="text-white/60 text-xs mt-0.5">
|
||||
<p class="text-orange-300 font-semibold text-xl">Bitcoin is syncing</p>
|
||||
<p class="text-white/70 mt-2 leading-relaxed">
|
||||
Some features may be unavailable until Bitcoin finishes syncing.
|
||||
Wallet connections and block data require a fully synced node.
|
||||
</p>
|
||||
@ -37,7 +35,7 @@
|
||||
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
|
||||
></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() }}
|
||||
</p>
|
||||
</div>
|
||||
@ -70,11 +68,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppScreenshot } from '@/types/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
features: string[]
|
||||
needsBitcoinSync: boolean
|
||||
@ -82,4 +82,28 @@ defineProps<{
|
||||
bitcoinSyncPercent: 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>
|
||||
|
||||
@ -1,225 +1,70 @@
|
||||
<template>
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<div class="flex items-start md:items-center gap-4 md:gap-6">
|
||||
<img
|
||||
:src="icon"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="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>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<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 flex-wrap items-center gap-2">
|
||||
<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'])"
|
||||
>
|
||||
<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']) }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<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 -->
|
||||
<div class="app-detail-actions-top items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
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...
|
||||
</span>
|
||||
<button
|
||||
v-if="packageKey === 'lnd'"
|
||||
@click="$emit('channels')"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
v-for="action in actionItems"
|
||||
:key="`top-${action.key}`"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{{ t('appDetails.channels') }}
|
||||
{{ action.label }}
|
||||
</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>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<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) -->
|
||||
<div class="app-detail-actions-bottom mt-5 grid grid-cols-2 gap-3">
|
||||
<template v-if="pkg.state === 'updating'">
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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...
|
||||
</span>
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@click="$emit('launch')"
|
||||
:class="isWebOnly ? 'col-span-2' : ''"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold 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="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 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>
|
||||
</template>
|
||||
<button
|
||||
v-for="action in actionItems"
|
||||
:key="`bottom-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="[
|
||||
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
action.class,
|
||||
actionItems.length === 1 || action.full ? 'col-span-2' : '',
|
||||
]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -229,6 +74,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -239,11 +85,13 @@ const props = defineProps<{
|
||||
packageKey: string
|
||||
canLaunch: boolean
|
||||
isWebOnly: boolean
|
||||
pendingAction: 'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null
|
||||
}>()
|
||||
|
||||
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: []
|
||||
start: []
|
||||
stop: []
|
||||
@ -253,10 +101,110 @@ defineEmits<{
|
||||
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) {
|
||||
const target = e.target as HTMLImageElement
|
||||
if (!target.src.includes('data:image') && !target.src.includes('logo-archipelago')) {
|
||||
target.src = '/assets/img/logo-archipelago.svg'
|
||||
if (!target.src.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = '1'
|
||||
}
|
||||
}
|
||||
</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 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>
|
||||
<p v-if="credentials.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<div class="space-y-3">
|
||||
<p v-if="credentials?.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<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 class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
@ -128,38 +143,44 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="pkg.manifest.website"
|
||||
:href="pkg.manifest.website"
|
||||
v-for="link in links"
|
||||
:key="link.kind"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</svg>
|
||||
{{ t('appDetails.website') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
v-else-if="link.kind === 'source'"
|
||||
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"/>
|
||||
</svg>
|
||||
{{ t('appDetails.sourceCode') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
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-else
|
||||
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" />
|
||||
</svg>
|
||||
{{ t('appDetails.documentation') }}
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -167,7 +188,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
|
||||
@ -191,7 +212,7 @@ async function copyCredential(label: string, value: string) {
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
packageKey: string
|
||||
isWebOnly: boolean
|
||||
@ -201,5 +222,40 @@ defineProps<{
|
||||
torUrl: string
|
||||
showTorAddress: boolean
|
||||
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>
|
||||
|
||||
@ -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" />
|
||||
</svg>
|
||||
</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">
|
||||
<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>
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
@ -88,6 +89,8 @@ const props = defineProps<{
|
||||
mustOpenNewTab: boolean
|
||||
autoRetryCount: number
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
|
||||
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('grafana')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('photoprism')).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', () => {
|
||||
@ -17,7 +23,17 @@ describe('appSessionConfig', () => {
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||
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', () => {
|
||||
@ -29,4 +45,14 @@ describe('appSessionConfig', () => {
|
||||
|
||||
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 */
|
||||
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
|
||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
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> = {
|
||||
...GENERATED_APP_PORTS,
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
@ -14,7 +17,6 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'saleor': 9011,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool': 4080,
|
||||
@ -71,8 +73,9 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
}
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
...GENERATED_APP_TITLES,
|
||||
'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',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'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) */
|
||||
export const NEW_TAB_APPS = new Set([
|
||||
'btcpay-server',
|
||||
'grafana',
|
||||
'photoprism',
|
||||
'homeassistant',
|
||||
'vaultwarden',
|
||||
'nextcloud',
|
||||
'uptime-kuma',
|
||||
'portainer',
|
||||
'onlyoffice',
|
||||
...GENERATED_NEW_TAB_APPS,
|
||||
'nginx-proxy-manager',
|
||||
'gitea',
|
||||
'tailscale',
|
||||
])
|
||||
|
||||
@ -100,7 +94,7 @@ export const NEW_TAB_APPS = new Set([
|
||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** 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
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
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
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
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'
|
||||
}
|
||||
|
||||
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.
|
||||
const port = APP_PORTS[id]
|
||||
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
|
||||
:src="icon"
|
||||
: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"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
@ -77,6 +77,9 @@
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
</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 -->
|
||||
<!-- Installing progress — replaces action buttons -->
|
||||
@ -207,7 +210,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import {
|
||||
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
|
||||
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
|
||||
getStatusClass, getStatusLabel, handleImageError,
|
||||
} from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
@ -284,6 +287,7 @@ const isTransitioning = computed(() => {
|
||||
const h = props.pkg.health
|
||||
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
|
||||
})
|
||||
const blockedReason = computed(() => launchBlockedReason(props.id, props.pkg))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -18,15 +18,21 @@
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="getTitle(id, pkg)"
|
||||
@pointerdown="startLongPress(id)"
|
||||
@pointerup="clearLongPress"
|
||||
@pointercancel="clearLongPress"
|
||||
@pointerleave="clearLongPress"
|
||||
@contextmenu.prevent="openAppOptions(id)"
|
||||
@click="handleTap(id, pkg)"
|
||||
@keydown.enter="handleTap(id, pkg)"
|
||||
@keydown.space.prevent="openAppOptions(id)"
|
||||
>
|
||||
<!-- Icon with status indicator -->
|
||||
<div class="app-icon-frame">
|
||||
<img
|
||||
:src="getIcon(id, pkg)"
|
||||
:alt="getTitle(id, pkg)"
|
||||
class="app-icon-img"
|
||||
class="app-icon-img archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- Status dot -->
|
||||
@ -44,7 +50,7 @@
|
||||
></span>
|
||||
<!-- Installing overlay -->
|
||||
<div
|
||||
v-if="serverStore.isInstalling(id)"
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-installing"
|
||||
>
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
@ -55,6 +61,13 @@
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<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>
|
||||
@ -72,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<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="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
@ -107,6 +120,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './appCredentials'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
@ -135,6 +149,8 @@ const emit = defineEmits<{
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const activePage = ref(0)
|
||||
const longPressTriggered = ref(false)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const pages = computed(() => {
|
||||
const result: [string, PackageDataEntry][][] = []
|
||||
@ -154,7 +170,22 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
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) {
|
||||
if (longPressTriggered.value) {
|
||||
longPressTriggered.value = false
|
||||
return
|
||||
}
|
||||
if (canLaunch(pkg)) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
||||
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) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
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> {
|
||||
try {
|
||||
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 = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: result.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: result.credentials,
|
||||
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
|
||||
} 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;
|
||||
-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 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -26,6 +26,19 @@
|
||||
<p class="text-white/70">
|
||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||
</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>
|
||||
|
||||
@ -37,7 +50,7 @@
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm')"
|
||||
@click="$emit('confirm', deleteAppData)"
|
||||
: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"
|
||||
>
|
||||
@ -61,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
@ -75,11 +88,12 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
confirm: [deleteAppData: boolean]
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
const deleteAppData = ref(false)
|
||||
|
||||
useModalKeyboard(
|
||||
modalRef,
|
||||
@ -87,4 +101,13 @@ useModalKeyboard(
|
||||
() => emit('close'),
|
||||
{ restoreFocusRef },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
deleteAppData.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-4">
|
||||
<!-- 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
@ -38,7 +38,7 @@
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- 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">
|
||||
<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>
|
||||
@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
|
||||
</div>
|
||||
@ -63,6 +63,16 @@
|
||||
|
||||
<!-- Channel List -->
|
||||
<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
|
||||
v-for="ch in channels"
|
||||
:key="ch.chan_id || ch.channel_point"
|
||||
@ -119,7 +129,7 @@
|
||||
</Transition>
|
||||
|
||||
<!-- 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">
|
||||
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
|
||||
|
||||
@ -165,7 +175,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
@ -232,6 +242,7 @@ function capacityPercent(amount: number, capacity: number): number {
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
const hadChannels = channels.value.length > 0
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
@ -246,6 +257,7 @@ async function loadChannels() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||
if (!hadChannels) channels.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -305,4 +317,6 @@ async function closeChannel() {
|
||||
}
|
||||
|
||||
onMounted(loadChannels)
|
||||
|
||||
defineExpose({ channels, loadChannels })
|
||||
</script>
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
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 { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import AppIconGrid from '../AppIconGrid.vue'
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
@ -31,8 +38,12 @@ function makePkg(id: string): PackageDataEntry {
|
||||
}
|
||||
|
||||
describe('AppIconGrid', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useRealTimers()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@ -51,14 +62,32 @@ describe('AppIconGrid', () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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 () => {
|
||||
@ -71,13 +100,78 @@ describe('AppIconGrid', () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['gitea', makePkg('gitea')]] },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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 { ref } from 'vue'
|
||||
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 {
|
||||
return {
|
||||
@ -81,4 +81,12 @@ describe('appsConfig service filtering', () => {
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
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)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'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',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'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',
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||
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 hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
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'
|
||||
}
|
||||
|
||||
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 {
|
||||
const addr = runtimeLanAddress(pkg)
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
@ -272,14 +288,8 @@ export function handleImageError(e: Event) {
|
||||
return
|
||||
}
|
||||
|
||||
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.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
|
||||
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = "1"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
uninstallingApps.add(appId)
|
||||
await store.uninstallPackage(appId)
|
||||
await store.uninstallPackage(appId, options)
|
||||
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
||||
// when the container actually disappears from backend data
|
||||
} 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 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 healthNotifications = computed(() => {
|
||||
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
|
||||
const seen = new Map<string, typeof visible[0]>()
|
||||
for (const n of visible) {
|
||||
@ -64,4 +83,14 @@ function dismissNotification(id: string) {
|
||||
}
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
<!-- Apps Grid -->
|
||||
<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
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
@ -124,15 +144,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0" class="text-center py-12">
|
||||
<div v-if="isLoading" 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">
|
||||
<div v-if="filteredApps.length === 0 && !isLoading" class="text-center py-12">
|
||||
<div v-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/40 text-sm">{{ nostrError }}</p>
|
||||
<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; }
|
||||
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>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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-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: '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: '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' },
|
||||
@ -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: '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: '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: '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' },
|
||||
@ -121,7 +120,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@ -191,7 +189,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
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('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('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||
|
||||
@ -67,6 +67,13 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="node in nodes"
|
||||
:key="node.nostr_pubkey"
|
||||
@ -189,4 +196,6 @@ function shortNpub(npub: string): string {
|
||||
if (!npub || npub.length < 16) return npub
|
||||
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
||||
}
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<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="flex items-center justify-between mb-6">
|
||||
<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">
|
||||
<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>
|
||||
<span class="text-white/60 text-sm">Loading nodes...</span>
|
||||
</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>
|
||||
|
||||
<!-- 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...
|
||||
</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 class="glass-card p-4">
|
||||
<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' },
|
||||
]
|
||||
|
||||
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 ---
|
||||
|
||||
export function useFleetData() {
|
||||
@ -125,6 +177,7 @@ export function useFleetData() {
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<FleetNode[]>([])
|
||||
const fleetAlerts = ref<FleetAlert[]>([])
|
||||
const refreshing = ref(false)
|
||||
const alertsLoading = ref(false)
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
||||
@ -166,26 +219,7 @@ export function useFleetData() {
|
||||
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
|
||||
})
|
||||
|
||||
const sortedNodes = computed(() => {
|
||||
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 sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
|
||||
|
||||
const allAppIds = computed(() => {
|
||||
const appSet = new Set<string>()
|
||||
@ -227,11 +261,11 @@ export function useFleetData() {
|
||||
|
||||
async function fetchFleetStatus() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
|
||||
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||
method: 'telemetry.fleet-status',
|
||||
})
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
}
|
||||
} catch (err) {
|
||||
@ -259,15 +293,12 @@ export function useFleetData() {
|
||||
|
||||
async function fetchNodeHistory(nodeId: string) {
|
||||
nodeHistoryLoading.value = true
|
||||
nodeHistory.value = []
|
||||
try {
|
||||
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
|
||||
const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
|
||||
method: 'telemetry.fleet-node-history',
|
||||
params: { node_id: nodeId },
|
||||
})
|
||||
if (data?.history) {
|
||||
nodeHistory.value = data.history
|
||||
}
|
||||
nodeHistory.value = normalizeNodeHistoryResponse(data)
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
@ -277,9 +308,14 @@ export function useFleetData() {
|
||||
|
||||
async function refreshAll() {
|
||||
loading.value = !nodes.value.length
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
loading.value = false
|
||||
try {
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(nodeId: string) {
|
||||
@ -288,7 +324,6 @@ export function useFleetData() {
|
||||
nodeHistory.value = []
|
||||
} else {
|
||||
selectedNodeId.value = nodeId
|
||||
fetchNodeHistory(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,7 +402,7 @@ export function useFleetData() {
|
||||
})
|
||||
|
||||
return {
|
||||
loading, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
||||
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
||||
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