feat(hub): hide standalones not provisioned on this deploy (aiolabs/webapp#129)

The hub rendered all 8 standalones statically and greyed out any whose
VITE_HUB_<APP>_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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-20 08:50:07 +02:00
commit 36791c8121

View file

@ -13,16 +13,29 @@ import PreferencesRow from '@/components/layout/PreferencesRow.vue'
const router = useRouter() const router = useRouter()
const { isAuthenticated } = useAuth() 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 { interface Module {
label: string label: string
chakra: string chakra: string
icon: any icon: any
bgClass: string bgClass: string
glow: string glow: string
/** Env var holding this app's deployed URL. Absent/empty ⇒ not provisioned. */
envKey?: string envKey?: string
/** Maturity badge ('alpha' | 'beta' | …), shown under the label. */
status?: string status?: string
/** When true, the tile is ghosted out unless the user is logged in. */ /** When true, the tile is ghosted out unless the user is logged in. */
authRequired?: boolean 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 count for the corner badge. Wire to real data via #32. */
unread?: number 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: '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 }, { 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') || '') const token = computed(() => localStorage.getItem('lnbits_access_token') || '')
function hubLink(m: Module): string | null { /**
if (!m.envKey) return null * Per-tile availability, resolved from deploy config (env URLs) + auth state.
// Auth-only modules (wallet, chat, libra, tasks) are ghosted when not logged in. * Explicit states keep the render path declarative and make each future step
if (m.authRequired && !isAuthenticated.value) return null * a small change rather than another overloaded null check.
const url = import.meta.env[m.envKey] as string | undefined */
if (!url) return null 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<Tile[]>(() =>
[...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) { if (isAuthenticated.value && token.value) {
const sep = url.includes('?') ? '&' : '?' const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(token.value)}` return `${url}${sep}token=${encodeURIComponent(token.value)}`
@ -56,22 +109,35 @@ function hubLink(m: Module): string | null {
return url return url
} }
function isAuthGated(m: Module): boolean { function tileTag(t: Tile): 'a' | 'button' | 'div' {
return !!(m.authRequired && !isAuthenticated.value) if (t.availability === 'available') return 'a'
if (t.availability === 'auth-locked') return 'button'
return 'div' // inactive
} }
function onTileClick(m: Module, event: Event) { function tileClass(t: Tile): string {
// Ghosted auth-required tiles aren't anchors; intercept and toast. switch (t.availability) {
if (isAuthGated(m)) { 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() event.preventDefault()
toast.info(`${m.label} requires login`, { toast.info(`${t.module.label} requires login`, {
action: { action: {
label: 'Log in', label: 'Log in',
onClick: () => router.push('/login'), onClick: () => router.push('/login'),
}, },
}) })
} }
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing. // 'inactive' tiles silently do nothing.
} }
</script> </script>
@ -102,33 +168,27 @@ function onTileClick(m: Module, event: Event) {
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0"> <div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
<component <component
v-for="m in orderedModules" v-for="t in tiles"
:key="m.label" :key="t.module.label"
:is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')" :is="tileTag(t)"
:href="hubLink(m) || undefined" :href="t.href"
class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0" class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0"
:class="[ :class="tileClass(t)"
hubLink(m) @click="onTileClick(t, $event)"
? 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
: isAuthGated(m)
? 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
: 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed',
]"
@click="onTileClick(m, $event)"
> >
<component :is="m.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${m.glow})` }" /> <component :is="t.module.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${t.module.glow})` }" />
<div class="text-center leading-tight"> <div class="text-center leading-tight">
<p class="text-sm font-semibold text-foreground drop-shadow">{{ m.label }}</p> <p class="text-sm font-semibold text-foreground drop-shadow">{{ t.module.label }}</p>
<p v-if="m.status" class="text-[9px] font-light text-muted-foreground">{{ m.status }}</p> <p v-if="t.module.status" class="text-[9px] font-light text-muted-foreground">{{ t.module.status }}</p>
</div> </div>
<!-- Notification badge wired to data once #32 lands. Hidden when unread is falsy/0. --> <!-- Notification badge wired to data once #32 lands. Hidden when unread is falsy/0. -->
<span <span
v-if="m.unread" v-if="t.module.unread"
class="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60" class="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60"
:aria-label="`${m.unread} unread`" :aria-label="`${t.module.unread} unread`"
> >
{{ m.unread > 99 ? '99+' : m.unread }} {{ t.module.unread > 99 ? '99+' : t.module.unread }}
</span> </span>
</component> </component>
</div> </div>