diff --git a/neode-ui/e2e/screenshots/02-dashboard-home.png b/neode-ui/e2e/screenshots/02-dashboard-home.png index 369f80a3..1e1dd278 100644 Binary files a/neode-ui/e2e/screenshots/02-dashboard-home.png and b/neode-ui/e2e/screenshots/02-dashboard-home.png differ diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index c33f17e5..594ea5dd 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -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(` + + + + + Archipelago dev backend + + +

This is the mock JSON-RPC backend. Open the dashboard at + http://localhost:${uiPort}/. +

+ +`) +}) + +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 diff --git a/neode-ui/public/packages/archipelago-companion.apk.zip b/neode-ui/public/packages/archipelago-companion.apk.zip new file mode 100644 index 00000000..43a3e9f3 Binary files /dev/null and b/neode-ui/public/packages/archipelago-companion.apk.zip differ diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 422c3479..665c89a0 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -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 = {} +let bodyLockedForModal = false +let modalTouchY: number | null = null + +function hasBlockingOverlay() { + if (typeof document === 'undefined') return false + return Array.from(document.querySelectorAll('.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('.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() }) /** diff --git a/neode-ui/src/api/__tests__/rpc-client.test.ts b/neode-ui/src/api/__tests__/rpc-client.test.ts index 468523cd..8901ec4b 100644 --- a/neode-ui/src/api/__tests__/rpc-client.test.ts +++ b/neode-ui/src/api/__tests__/rpc-client.test.ts @@ -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') diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 27442ff5..c55363ff 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -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, }) } diff --git a/neode-ui/src/components/BaseModal.vue b/neode-ui/src/components/BaseModal.vue index f9c15474..2815b1bf 100644 --- a/neode-ui/src/components/BaseModal.vue +++ b/neode-ui/src/components/BaseModal.vue @@ -39,6 +39,7 @@ diff --git a/neode-ui/src/views/MarketplaceAppDetails.vue b/neode-ui/src/views/MarketplaceAppDetails.vue index aa860f84..acfc0bb8 100644 --- a/neode-ui/src/views/MarketplaceAppDetails.vue +++ b/neode-ui/src/views/MarketplaceAppDetails.vue @@ -201,20 +201,18 @@
-
+

{{ t('marketplaceDetails.screenshots') }}

-
- - - -
+
-

{{ t('marketplaceDetails.screenshotPlaceholder') }}

@@ -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 [ diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index c15324bd..e63256a8 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -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 {
{{ mesh.error }}
-
+
@@ -1727,8 +1735,7 @@ function isImageMime(mime?: string): boolean {
- -
+
- -
+
diff --git a/neode-ui/src/views/OnboardingOptions.vue b/neode-ui/src/views/OnboardingOptions.vue index 3b52d10d..3417c777 100644 --- a/neode-ui/src/views/OnboardingOptions.vue +++ b/neode-ui/src/views/OnboardingOptions.vue @@ -14,7 +14,7 @@

How would you like to get started?

-
+
- - -
-
-
- - - -
-
-

Connect Existing

-

- Connect to an existing Archipelago server -

- (Coming Soon) -
diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 6d0d39d9..1369a862 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -38,7 +38,7 @@
-
+
@@ -47,7 +47,7 @@
-
+
{{ catalogError }}
@@ -67,12 +67,23 @@
-
-
+
+
+ + + + + Refreshing peer files... +
+
+ {{ catalogError }} +
+
+
+
@@ -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 }) diff --git a/neode-ui/src/views/appDetails/AppSidebar.vue b/neode-ui/src/views/appDetails/AppSidebar.vue index ff9644bd..e9289436 100644 --- a/neode-ui/src/views/appDetails/AppSidebar.vue +++ b/neode-ui/src/views/appDetails/AppSidebar.vue @@ -86,10 +86,25 @@
-
+ +
+

{{ t('appDetails.setupInstructions') }}

+

+ {{ setupInstructions }} +

+
+ +

Credentials

-

{{ credentials.description }}

-
+

{{ credentials.description }}

+
+
+
+
+
+

Loading credentials...

+
+
{{ cred.label }} @@ -128,38 +143,44 @@
-
+ @@ -167,7 +188,7 @@ diff --git a/neode-ui/src/views/appDetails/__tests__/AppContentSection.test.ts b/neode-ui/src/views/appDetails/__tests__/AppContentSection.test.ts new file mode 100644 index 00000000..caed07f2 --- /dev/null +++ b/neode-ui/src/views/appDetails/__tests__/AppContentSection.test.ts @@ -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 { + 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') + }) +}) diff --git a/neode-ui/src/views/appDetails/__tests__/AppHeroSection.test.ts b/neode-ui/src/views/appDetails/__tests__/AppHeroSection.test.ts new file mode 100644 index 00000000..cb70e757 --- /dev/null +++ b/neode-ui/src/views/appDetails/__tests__/AppHeroSection.test.ts @@ -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 { + 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['$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) + }) +}) diff --git a/neode-ui/src/views/appDetails/__tests__/AppSidebar.test.ts b/neode-ui/src/views/appDetails/__tests__/AppSidebar.test.ts new file mode 100644 index 00000000..0fe0feb6 --- /dev/null +++ b/neode-ui/src/views/appDetails/__tests__/AppSidebar.test.ts @@ -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, propOverrides: Record = {}) { + 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') + }) +}) diff --git a/neode-ui/src/views/appSession/AppSessionFrame.vue b/neode-ui/src/views/appSession/AppSessionFrame.vue index cf750f9d..c3a19287 100644 --- a/neode-ui/src/views/appSession/AppSessionFrame.vue +++ b/neode-ui/src/views/appSession/AppSessionFrame.vue @@ -37,9 +37,10 @@
-

{{ mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable' }}

+

{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}

+

@@ -88,6 +89,8 @@ const props = defineProps<{ mustOpenNewTab: boolean autoRetryCount: number refreshKey: number + blockedReason?: string + blockedTitle?: string }>() const emit = defineEmits<{ diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index 411bfc26..186b3654 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -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') + }) }) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index dcf26861..3becf72d 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -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 = { + ...GENERATED_APP_PORTS, 'bitcoin-knots': 8334, 'bitcoin-core': 8334, 'bitcoin-ui': 8334, @@ -14,7 +17,6 @@ export const APP_PORTS: Record = { '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 = { } export const APP_TITLES: Record = { + ...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 = { /** 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([]) /** 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 '' diff --git a/neode-ui/src/views/appStoreCategories.ts b/neode-ui/src/views/appStoreCategories.ts new file mode 100644 index 00000000..f5d0797c --- /dev/null +++ b/neode-ui/src/views/appStoreCategories.ts @@ -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 diff --git a/neode-ui/src/views/apps/AppCard.vue b/neode-ui/src/views/apps/AppCard.vue index 5fff698a..096de0ad 100644 --- a/neode-ui/src/views/apps/AppCard.vue +++ b/neode-ui/src/views/apps/AppCard.vue @@ -31,7 +31,7 @@
@@ -77,6 +77,9 @@ {{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
+

+ {{ blockedReason }} +

@@ -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)) diff --git a/neode-ui/src/views/discover/FilterModal.vue b/neode-ui/src/views/discover/FilterModal.vue index dabf6517..7679b6d7 100644 --- a/neode-ui/src/views/discover/FilterModal.vue +++ b/neode-ui/src/views/discover/FilterModal.vue @@ -16,7 +16,7 @@
diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 39e9f528..e1452a4f 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -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 = { 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' diff --git a/neode-ui/src/views/federation/DiscoverModal.vue b/neode-ui/src/views/federation/DiscoverModal.vue index a89c057b..807b6480 100644 --- a/neode-ui/src/views/federation/DiscoverModal.vue +++ b/neode-ui/src/views/federation/DiscoverModal.vue @@ -67,6 +67,13 @@
+
+ + + + + Searching relays... +
diff --git a/neode-ui/src/views/federation/NodeDetailModal.vue b/neode-ui/src/views/federation/NodeDetailModal.vue index e6940a6b..843446ee 100644 --- a/neode-ui/src/views/federation/NodeDetailModal.vue +++ b/neode-ui/src/views/federation/NodeDetailModal.vue @@ -1,5 +1,5 @@