From 36791c81217e7439bb0120f8d18e6b1a81d9cb3b Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 20 Jun 2026 08:50:07 +0200 Subject: [PATCH] feat(hub): hide standalones not provisioned on this deploy (aiolabs/webapp#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub rendered all 8 standalones statically and greyed out any whose VITE_HUB__URL was unset — so an events-only deploy showed 7 dead "coming soon" tiles. The greyed path was overloaded: hubLink() returned null for both "not deployed" and "logged out", which the template then couldn't tell apart. Replace that with a registry → resolver → view-model pipeline: - availabilityOf(m) resolves an explicit state from deploy config + auth: available | auth-locked | inactive | unavailable. - A `tiles` computed maps the catalog to view-models and drops 'unavailable' (not provisioned) entirely — no more ghost tiles. - 'auth-locked' keeps today's greyed + login-prompt behavior for deployed-but-logged-out apps (wallet, chat, tasks, libra). - 'inactive' (active:false) is wired as a greyed, inert state — an unused seam for a future install/disable model; no app sets it yet. The view treats the catalog as an opaque list, so the source can later move from this in-code array to a runtime feed (LNbits /hub/apps or a NIP-78 event) without touching the render path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/Hub.vue | 126 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 93 insertions(+), 33 deletions(-) diff --git a/src/pages/Hub.vue b/src/pages/Hub.vue index 34692a5..2e4fed5 100644 --- a/src/pages/Hub.vue +++ b/src/pages/Hub.vue @@ -13,16 +13,29 @@ import PreferencesRow from '@/components/layout/PreferencesRow.vue' const router = useRouter() const { isAuthenticated } = useAuth() +// The app registry. Today this is a static in-code catalog; the view below +// treats it as an opaque list, so the source can later move to a runtime +// feed (an LNbits /hub/apps response or a NIP-78 replaceable event) to +// support installing/removing apps per-instance without a rebuild — without +// touching the render path. interface Module { label: string chakra: string icon: any bgClass: string glow: string + /** Env var holding this app's deployed URL. Absent/empty ⇒ not provisioned. */ envKey?: string + /** Maturity badge ('alpha' | 'beta' | …), shown under the label. */ status?: string /** When true, the tile is ghosted out unless the user is logged in. */ authRequired?: boolean + /** + * Soft kill-switch for a *provisioned* app: false ⇒ rendered greyed-out + * and non-clickable ('inactive') even though it's deployed. Default (unset) + * is active. Seam for a future install/disable model; no app sets it yet. + */ + active?: boolean /** Unread count for the corner badge. Wire to real data via #32. */ unread?: number } @@ -38,17 +51,57 @@ const modules: Module[] = [ { label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true }, { label: 'Libra', chakra: 'Sahasrara', icon: Scale, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_LIBRA_URL', status: 'beta', authRequired: true }, ] -// Crown at top, root at bottom -const orderedModules = computed(() => [...modules].reverse()) const token = computed(() => localStorage.getItem('lnbits_access_token') || '') -function hubLink(m: Module): string | null { - if (!m.envKey) return null - // Auth-only modules (wallet, chat, libra, tasks) are ghosted when not logged in. - if (m.authRequired && !isAuthenticated.value) return null - const url = import.meta.env[m.envKey] as string | undefined - if (!url) return null +/** + * Per-tile availability, resolved from deploy config (env URLs) + auth state. + * Explicit states keep the render path declarative and make each future step + * a small change rather than another overloaded null check. + */ +type Availability = + | 'available' // provisioned + reachable → link + | 'auth-locked' // provisioned, needs login, logged out → greyed, prompts login + | 'inactive' // provisioned but switched off (active:false) → greyed, inert + | 'unavailable' // not provisioned on this instance → hidden entirely + +/** This app's deployed URL, or undefined when not provisioned on this build. */ +function appUrl(m: Module): string | undefined { + const url = m.envKey ? (import.meta.env[m.envKey] as string | undefined) : undefined + return url || undefined +} + +function availabilityOf(m: Module): Availability { + if (!appUrl(m)) return 'unavailable' + if (m.active === false) return 'inactive' + if (m.authRequired && !isAuthenticated.value) return 'auth-locked' + return 'available' +} + +interface Tile { + module: Module + availability: Availability + /** Final href (with auth token appended) — only set for 'available'. */ + href?: string +} + +// Crown at top, root at bottom. Unavailable (not-deployed) apps are dropped +// here, so the grid only ever renders provisioned tiles. +const tiles = computed(() => + [...modules] + .reverse() + .map((m) => { + const availability = availabilityOf(m) + return { + module: m, + availability, + href: availability === 'available' ? withToken(appUrl(m)!) : undefined, + } + }) + .filter((t) => t.availability !== 'unavailable'), +) + +function withToken(url: string): string { if (isAuthenticated.value && token.value) { const sep = url.includes('?') ? '&' : '?' return `${url}${sep}token=${encodeURIComponent(token.value)}` @@ -56,22 +109,35 @@ function hubLink(m: Module): string | null { return url } -function isAuthGated(m: Module): boolean { - return !!(m.authRequired && !isAuthenticated.value) +function tileTag(t: Tile): 'a' | 'button' | 'div' { + if (t.availability === 'available') return 'a' + if (t.availability === 'auth-locked') return 'button' + return 'div' // inactive } -function onTileClick(m: Module, event: Event) { - // Ghosted auth-required tiles aren't anchors; intercept and toast. - if (isAuthGated(m)) { +function tileClass(t: Tile): string { + switch (t.availability) { + case 'available': + return 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer' + case 'auth-locked': + return 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer' + default: // inactive + return 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed' + } +} + +function onTileClick(t: Tile, event: Event) { + // Auth-locked tiles aren't anchors; intercept and prompt login. + if (t.availability === 'auth-locked') { event.preventDefault() - toast.info(`${m.label} requires login`, { + toast.info(`${t.module.label} requires login`, { action: { label: 'Log in', onClick: () => router.push('/login'), }, }) } - // "Coming soon" tiles (no envKey, no authRequired) silently do nothing. + // 'inactive' tiles silently do nothing. } @@ -102,33 +168,27 @@ function onTileClick(m: Module, event: Event) {
- +
-

{{ m.label }}

-

{{ m.status }}

+

{{ t.module.label }}

+

{{ t.module.status }}

- {{ m.unread > 99 ? '99+' : m.unread }} + {{ t.module.unread > 99 ? '99+' : t.module.unread }}