feat(neode-ui): mobile app-launch UX — store-driven panel, loader, ElectrumX icon

- Mobile launches use the store-driven panel (no route push) so the background
  tab no longer changes and closing returns to where you launched from.
- Tab-only apps open directly (in-app WebView on companion / new tab on PWA) —
  no "this app opens in a tab" interstitial.
- Shared AppLoadingScreen (app icon + progress bar) on the app session and the
  legacy iframe overlay instead of a black screen.
- Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding
  under the bottom tab bar in mobile browsers (no-op in the companion WebView).
- ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps.
- isMobile made reactive so overlay/footer/teleport decisions track the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-23 03:48:57 -04:00
co-authored by Claude Opus 4.8
parent db7d424bff
commit a7c7c44843
11 changed files with 387 additions and 56 deletions
+108 -4
View File
@@ -10,7 +10,14 @@ export type AppsTab = 'apps' | 'websites' | 'services'
// Service container name patterns (backend/infra, not user-facing)
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
'immich_postgres', 'immich_redis',
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres', 'immich-redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
@@ -37,7 +44,10 @@ export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
if (id.startsWith('archy-')) return true
if (id.endsWith('_db') || id.endsWith('-db')) return true
// Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
if (id.endsWith('_db')) return true
return false
}
@@ -96,8 +106,11 @@ export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
// Curated known apps stay in My Apps even if their manifest predates the UI
// interface field.
if (isKnownApp(id, pkg)) return false
// Fallback: reachable on the LAN but declares no UI → treat as a website.
return !!pkg && !!runtimeLanAddress(pkg)
// Anything still here has no declared UI and isn't a known launcher app:
// databases, APIs, backends, workers. They belong in Services (not My Apps),
// whether or not they expose a LAN address. (#10 — "anything that isn't the
// frontend UI launcher".)
return !!pkg
}
export function filterEntriesForTab(
@@ -113,10 +126,33 @@ export function filterEntriesForTab(
if (activeTab === 'apps' && selectedCategory !== 'all') {
return getAppCategory(id, pkg) === selectedCategory
}
if (activeTab === 'services' && selectedCategory !== 'all') {
return getServiceCategory(id, pkg) === selectedCategory
}
return true
})
}
// Group a (non-launcher) service container by type for the Services tab sub-nav
// (#12). Heuristic over the container id + manifest id.
export function getServiceCategory(id: string, pkg?: PackageDataEntry): string {
const s = `${id} ${pkg?.manifest?.id || ''}`.toLowerCase()
if (/postgres|mariadb|mysql|(^|[-_])db([-_]|$)/.test(s)) return 'database'
if (/redis|valkey|(^|[-_])cache([-_]|$)/.test(s)) return 'cache'
if (/(^|[-_])api([-_]|$)/.test(s)) return 'api'
return 'backend'
}
export function buildServiceCategories(t: (key: string) => string): Array<{ id: string; name: string }> {
return [
{ id: 'all', name: t('marketplace.all') },
{ id: 'database', name: 'Databases' },
{ id: 'cache', name: 'Caches' },
{ id: 'api', name: 'APIs' },
{ id: 'backend', name: 'Backends' },
]
}
// Web-only app IDs and their URLs
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
@@ -178,8 +214,56 @@ export function opensInTab(id: string): boolean {
return TAB_LAUNCH_APPS.has(id)
}
// Backend services that ship no icon of their own reuse their PARENT app's icon
// (#14) so they render the app's logo instead of a 404 → 📦 placeholder. Paths
// are explicit because icon extensions vary (.png / .webp / .svg).
const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
// immich stack
'immich-postgres': '/assets/img/app-icons/immich.png',
'immich-redis': '/assets/img/app-icons/immich.png',
'immich-server': '/assets/img/app-icons/immich.png',
'immich_postgres': '/assets/img/app-icons/immich.png',
'immich_redis': '/assets/img/app-icons/immich.png',
// btcpay stack
'archy-btcpay-db': '/assets/img/app-icons/btcpay-server.png',
'archy-nbxplorer': '/assets/img/app-icons/btcpay-server.png',
// mempool stack
'archy-mempool-db': '/assets/img/app-icons/mempool.webp',
'mempool-api': '/assets/img/app-icons/mempool.webp',
'archy-mempool-web': '/assets/img/app-icons/mempool.webp',
'mysql-mempool': '/assets/img/app-icons/mempool.webp',
// bitcoin / lightning companion UIs
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
'archy-lnd-ui': '/assets/img/app-icons/lnd.svg',
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
// ElectrumX ships under a few historical ids (the backend was renamed
// electrs → electrumx). Without an explicit map, an `electrs`-keyed install
// falls through to the default `/assets/img/app-icons/electrs.png`, which
// doesn't exist → handleImageError swaps .png→.svg and lands on electrs.svg
// (the "Electrs in Rust" logo) instead of the real ElectrumX icon. Pin the
// whole family to the ElectrumX icon so My Apps shows the right logo no
// matter which id the node has it installed under.
'electrs': '/assets/img/app-icons/electrumx.png',
'electrs-ui': '/assets/img/app-icons/electrumx.png',
'electrumx': '/assets/img/app-icons/electrumx.png',
}
// Parent-app icon by prefix, for stack members not listed explicitly above
// (e.g. every indeedhub-* sub-container → indeedhub).
const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
['immich-', '/assets/img/app-icons/immich.png'],
['immich_', '/assets/img/app-icons/immich.png'],
]
function serviceParentIcon(id: string): string | undefined {
for (const [prefix, icon] of SERVICE_ICON_PREFIXES) {
if (id.startsWith(prefix)) return icon
}
return undefined
}
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
@@ -195,7 +279,12 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
) {
return icon
}
return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png`
return (
curatedIcon ||
APP_ICON_FALLBACKS[id] ||
serviceParentIcon(id) ||
`/assets/img/app-icons/${id}.png`
)
}
export function canLaunch(pkg: PackageDataEntry): boolean {
@@ -302,6 +391,21 @@ export function useCategoriesWithApps(
})
}
// Services-tab equivalent of useCategoriesWithApps: only show a service category
// when at least one installed service belongs to it (#12).
export function useServiceCategories(
packages: Ref<Record<string, PackageDataEntry>>,
serviceCategories: Ref<Array<{ id: string; name: string }>>,
) {
return computed(() => {
const entries = Object.entries(packages.value).filter(([id, pkg]) => isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
return serviceCategories.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getServiceCategory(id, pkg) === cat.id)
})
})
}
export function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
const currentSrc = target.src