feat(layout): re-enable "Back to hub" with a sticky sheet footer #128

Merged
padreug merged 1 commit from feat/back-to-hub-sticky-footer into dev 2026-06-19 22:28:44 +00:00
11 changed files with 183 additions and 149 deletions

View file

@ -66,9 +66,7 @@ const npubPreview = computed(() => {
return value.length > 24 ? `${value.slice(0, 12)}${value.slice(-8)}` : value return value.length > 24 ? `${value.slice(0, 12)}${value.slice(-8)}` : value
}) })
// "Back to hub" is hidden for the events-only launch (see template). const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
// Re-enable this alongside the commented-out link below when the hub ships.
// const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
const copiedField = ref<string | null>(null) const copiedField = ref<string | null>(null)
async function copyToClipboard(text: string, field: string) { async function copyToClipboard(text: string, field: string) {
@ -105,6 +103,14 @@ async function onLogout() {
</script> </script>
<template> <template>
<!-- Fill the sheet exactly (h-full) so the footer stays stuck to the
bottom while only the region above it scrolls. The sheet host already
has overflow-y-auto, but with an exact-fit child it never triggers
the inner flex-1 region owns the scroll instead. -->
<div class="flex h-full flex-col">
<!-- Scrollable region: everything above the pinned footer. min-h-0 lets
this flex child shrink below its content height so it can scroll. -->
<div class="flex-1 min-h-0 overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle> <SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<SheetDescription v-if="isAuthenticated"> <SheetDescription v-if="isAuthenticated">
@ -190,31 +196,30 @@ async function onLogout() {
<!-- Cross-app links + global preferences (always visible, auth or not) --> <!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4"> <div class="mt-4">
<!-- "Back to hub" hidden for the events-only launch re-enable when <PreferencesRow layout="list" />
the hub ships. When re-enabled, show the HUB's brand-kit logo </div>
(the brand's primary/global logo, or a hub-specific override) </div>
NOT the per-standalone @brand-app-logo, which resolves to this
standalone's own logo. This needs a hub-logo alias (e.g. <!-- Pinned footer: stays stuck to the bottom of the sheet (shrink-0);
@brand-hub-logo = resolveAppLogo for the hub) added in "Back to hub" sits directly above the log-in/out bar. -->
vite-branding.ts + the standalone vite configs. <div class="shrink-0 pt-1">
<!-- "Back to hub" shows the HUB's brand-kit logo (the brand's
primary/global logo via @brand-hub-logo) NOT the per-standalone
@brand-app-logo, which resolves to this standalone's own logo. -->
<a <a
:href="hubRootUrl" :href="hubRootUrl"
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors" class="flex items-center justify-center gap-3 px-3 py-1.5 hover:bg-accent rounded-md transition-colors"
:aria-label="t('common.nav.backToHub')" :aria-label="t('common.nav.backToHub')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="@brand-hub-logo" :alt="t('common.nav.backToHub')" class="w-5 h-5 shrink-0" /> <img src="@brand-hub-logo" :alt="t('common.nav.backToHub')" class="w-8 h-8 shrink-0" />
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span> <span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</div> </div>
</a> </a>
-->
<PreferencesRow layout="list" />
</div>
<!-- Logged-out: prominent log-in CTA --> <!-- Logged-out: prominent log-in CTA -->
<div v-if="!isAuthenticated" class="mt-6"> <div v-if="!isAuthenticated">
<Separator class="mb-4" /> <Separator class="mb-2" />
<Button class="w-full" @click="goLogin"> <Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" /> <LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }} {{ t('common.nav.login') }}
@ -222,8 +227,8 @@ async function onLogout() {
</div> </div>
<!-- Logged-in: log-out button stays visible without opening the edit popup. --> <!-- Logged-in: log-out button stays visible without opening the edit popup. -->
<div v-else class="mt-6"> <div v-else>
<Separator class="mb-4" /> <Separator class="mb-2" />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button variant="destructive" class="w-full"> <Button variant="destructive" class="w-full">
@ -252,9 +257,12 @@ async function onLogout() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</div>
</div>
<!-- Edit-profile popup (pencil button in the identity card) the full <!-- Edit-profile popup (pencil button in the identity card) the full
form lives here so the sheet stays scannable. --> form lives here so the sheet stays scannable. Outside the flex root:
its content portals to <body>, so it's not part of the sheet flow. -->
<Dialog v-model:open="editProfileOpen"> <Dialog v-model:open="editProfileOpen">
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden"> <DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader> <DialogHeader>

View file

@ -115,6 +115,22 @@ export function brandAppLogoAliasEntry(app?: string) {
} as const } as const
} }
/**
* Hub-logo alias entry. Resolves `@brand-hub-logo` to the brand's
* primary/global logo (the hub's logo), independent of which standalone
* is building. Unlike {@link brandAppLogoAliasEntry}, this never takes an
* `app` argument the "Back to hub" link in every standalone must point
* at the HUB's logo, not the current standalone's own logo. Wire it into
* every vite.<app>.config.ts that builds ProfileSheetContent.vue.
*/
export function brandHubLogoAliasEntry() {
const resolved = resolveAppLogo()
return {
find: /^@brand-hub-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/** /**
* Optional brand banner a wide lockup (logo + wordmark in one image) * Optional brand banner a wide lockup (logo + wordmark in one image)
* that replaces the logo + app-name pair in a standalone's header. * that replaces the logo + app-name pair in a standalone's header.

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function chatHtmlPlugin(): Plugin { function chatHtmlPlugin(): Plugin {
return { return {
@ -107,6 +107,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('chat'), brandAppLogoAliasEntry('chat'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @/app.config must precede @ (first-match-wins). // ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)) }, { find: '@/app.config', replacement: fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)) },

View file

@ -5,7 +5,7 @@ import { defineConfig } from 'vite'
import Inspect from 'vite-plugin-inspect' import Inspect from 'vite-plugin-inspect'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
// https://vite.dev/config/ // https://vite.dev/config/
// //
@ -53,6 +53,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare alias. // with optional `?url` query) doesn't get shadowed by the bare alias.
alias: [ alias: [
brandAppLogoAliasEntry(), brandAppLogoAliasEntry(),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
] ]

View file

@ -11,6 +11,7 @@ import {
brandAppBannerAliasEntry, brandAppBannerAliasEntry,
brandAppLogoAliasEntry, brandAppLogoAliasEntry,
brandAssetsPlugin, brandAssetsPlugin,
brandHubLogoAliasEntry,
brandManifestName, brandManifestName,
resolveAppBanner, resolveAppBanner,
} from './vite-branding' } from './vite-branding'
@ -137,6 +138,7 @@ export default defineConfig(({ mode }) => ({
alias: [ alias: [
brandAppLogoAliasEntry('events'), brandAppLogoAliasEntry('events'),
brandAppBannerAliasEntry('events'), brandAppBannerAliasEntry('events'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
], ],

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function forumHtmlPlugin(): Plugin { function forumHtmlPlugin(): Plugin {
return { return {
@ -107,6 +107,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('forum'), brandAppLogoAliasEntry('forum'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @/app.config must precede @ (first-match-wins). // ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)) }, { find: '@/app.config', replacement: fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)) },

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to libra.html * Plugin to rewrite dev server requests to libra.html
@ -114,6 +114,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('libra'), brandAppLogoAliasEntry('libra'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @rollup/plugin-alias is first-match-wins. // ORDER MATTERS — @rollup/plugin-alias is first-match-wins.
// The more specific @/app.config remap must precede the @ prefix // The more specific @/app.config remap must precede the @ prefix

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function marketHtmlPlugin(): Plugin { function marketHtmlPlugin(): Plugin {
return { return {
@ -107,6 +107,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('market'), brandAppLogoAliasEntry('market'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @/app.config must precede @ (first-match-wins). // ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)) }, { find: '@/app.config', replacement: fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)) },

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function restaurantHtmlPlugin(): Plugin { function restaurantHtmlPlugin(): Plugin {
return { return {
@ -114,6 +114,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('restaurant'), brandAppLogoAliasEntry('restaurant'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @/app.config must precede @ (first-match-wins). // ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)) }, { find: '@/app.config', replacement: fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)) },

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function tasksHtmlPlugin(): Plugin { function tasksHtmlPlugin(): Plugin {
return { return {
@ -107,6 +107,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('tasks'), brandAppLogoAliasEntry('tasks'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @/app.config must precede @ (first-match-wins). // ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)) }, { find: '@/app.config', replacement: fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)) },

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin } from './vite-branding' import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to wallet.html * Plugin to rewrite dev server requests to wallet.html
@ -113,6 +113,7 @@ export default defineConfig(({ mode }) => ({
// with optional `?url` query) doesn't get shadowed by the bare aliases. // with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [ alias: [
brandAppLogoAliasEntry('wallet'), brandAppLogoAliasEntry('wallet'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
// ORDER MATTERS — @/app.config must precede @ (first-match-wins). // ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)) }, { find: '@/app.config', replacement: fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)) },