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:
parent
2cc8e34b9d
commit
36791c8121
1 changed files with 93 additions and 33 deletions
|
|
@ -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<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) {
|
||||
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.
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -102,33 +168,27 @@ function onTileClick(m: Module, event: Event) {
|
|||
|
||||
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<component
|
||||
v-for="m in orderedModules"
|
||||
:key="m.label"
|
||||
:is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')"
|
||||
:href="hubLink(m) || undefined"
|
||||
v-for="t in tiles"
|
||||
:key="t.module.label"
|
||||
:is="tileTag(t)"
|
||||
: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="[
|
||||
hubLink(m)
|
||||
? '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)"
|
||||
:class="tileClass(t)"
|
||||
@click="onTileClick(t, $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">
|
||||
<p class="text-sm font-semibold text-foreground drop-shadow">{{ m.label }}</p>
|
||||
<p v-if="m.status" class="text-[9px] font-light text-muted-foreground">{{ m.status }}</p>
|
||||
<p class="text-sm font-semibold text-foreground drop-shadow">{{ t.module.label }}</p>
|
||||
<p v-if="t.module.status" class="text-[9px] font-light text-muted-foreground">{{ t.module.status }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Notification badge — wired to data once #32 lands. Hidden when unread is falsy/0. -->
|
||||
<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"
|
||||
: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>
|
||||
</component>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue