feat(webapp): replace HubPill with hamburger sidebar menu
The top-right "Back to hub" pill in each standalone is replaced by a hamburger button that opens a right-side sheet reusing the existing ProfileSheetContent (identity card, back-to-hub link, theme/lang/ currency prefs, profile settings or log-in CTA). The redundant Profile entry is removed from BottomNav and its loggedOutOpensSheet plumbing (BottomNav → AppShell) is dropped — Hub.vue still mounts ProfileSheetTrigger directly so it's unaffected. ProfileSheetContent gains an `app-nav` slot so standalones can inject app-specific nav items above the cross-app section. AppShell exposes a new optional `sidebarNav` prop that forwards items to the menu; unset on non-activities standalones, those still get the hamburger menu showing just the shared profile/preferences content. Activities passes "My tickets" (routes to /my-tickets) and "Hosting" (toggles the onlyHosting feed filter and lands on /activities), so those entries leave the inline filter chip row on ActivitiesPage and live in the sidebar instead. The "Past events" chip stays inline — it doesn't require auth and pairs visually with the temporal filters.
This commit is contained in:
parent
e822285b99
commit
39678126c9
11 changed files with 135 additions and 79 deletions
|
|
@ -4,24 +4,23 @@ import { useRoute } from 'vue-router'
|
|||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import BottomNav, { type BottomTab } from './BottomNav.vue'
|
||||
import HubPill from './HubPill.vue'
|
||||
import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue'
|
||||
|
||||
interface Props {
|
||||
/** App-specific tabs displayed before the constant Profile entry. */
|
||||
tabs: BottomTab[]
|
||||
/** Active-tab matcher. Forwarded to BottomNav. */
|
||||
isActive: (path: string) => boolean
|
||||
/** Hide the top-right HubPill — only true when this shell is rendering
|
||||
* the hub itself. Standalones leave this false (default). */
|
||||
/** Hide the top-right standalone menu — only true when this shell is
|
||||
* rendering the hub itself. Standalones leave this false (default). */
|
||||
hideHub?: boolean
|
||||
/** Forwarded to BottomNav. Hub passes true so logged-out users can still
|
||||
* reach prefs from the sheet. Standalones leave it false. */
|
||||
loggedOutOpensSheet?: boolean
|
||||
/** App-specific nav items rendered at the top of the standalone menu. */
|
||||
sidebarNav?: SidebarNavItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hideHub: false,
|
||||
loggedOutOpensSheet: false,
|
||||
sidebarNav: () => [],
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -45,11 +44,13 @@ const isLoginPage = computed(() => route.path === '/login')
|
|||
v-if="!isLoginPage"
|
||||
:tabs="props.tabs"
|
||||
:is-active="props.isActive"
|
||||
:logged-out-opens-sheet="props.loggedOutOpensSheet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HubPill v-if="!props.hideHub && !isLoginPage" />
|
||||
<StandaloneMenu
|
||||
v-if="!props.hideHub && !isLoginPage"
|
||||
:items="props.sidebarNav"
|
||||
/>
|
||||
<Toaster />
|
||||
|
||||
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Component } from 'vue'
|
||||
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
|
||||
|
||||
export interface BottomTab {
|
||||
/** Translated label shown under the icon. */
|
||||
|
|
@ -25,13 +24,9 @@ interface Props {
|
|||
/** Active-tab matcher. Each app has its own nesting rules so we don't try
|
||||
* to derive a one-size-fits-all default — consumer supplies the function. */
|
||||
isActive: (path: string) => boolean
|
||||
/** When true (Hub), the unauthenticated profile button still opens the
|
||||
* sheet so logged-out users can change theme/lang. When false (standalones),
|
||||
* unauth profile button routes straight to /login. */
|
||||
loggedOutOpensSheet?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -73,10 +68,6 @@ function onTabClick(tab: BottomTab) {
|
|||
{{ tab.badge > 99 ? '99+' : tab.badge }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Always-on Profile entry, appended on the right. Consumers don't
|
||||
pass it; the shell owns it so it's identical across every app. -->
|
||||
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Home } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Falls back to '/' for path-mount deployments where the hub root is the
|
||||
* same origin. Set VITE_HUB_ROOT_URL to a full URL for subdomain
|
||||
* deployments where the hub lives on a sibling origin. */
|
||||
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="hubRootUrl"
|
||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center gap-1.5 rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||
:aria-label="t('common.nav.backToHub')"
|
||||
>
|
||||
<Home class="w-3.5 h-3.5" />
|
||||
<span class="hidden sm:inline">{{ t('common.nav.hub') }}</span>
|
||||
</a>
|
||||
</template>
|
||||
|
|
@ -53,6 +53,9 @@ function goLogin() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
|
||||
<slot name="app-nav" />
|
||||
|
||||
<!-- Cross-app links + global preferences (always visible, auth or not) -->
|
||||
<div class="mt-4">
|
||||
<a
|
||||
|
|
|
|||
81
src/components/layout/StandaloneMenu.vue
Normal file
81
src/components/layout/StandaloneMenu.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import ProfileSheetContent from './ProfileSheetContent.vue'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
/** Display label. */
|
||||
name: string
|
||||
/** Lucide (or any) component to render as the leading icon. */
|
||||
icon: Component
|
||||
/** Optional route to navigate to on click. */
|
||||
path?: string
|
||||
/** Optional click handler. Runs after navigation if both are set. */
|
||||
onClick?: () => void
|
||||
/** Visual-only "active" predicate for highlight state. */
|
||||
isActive?: () => boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** App-specific nav items rendered at the top of the sheet. */
|
||||
items?: SidebarNavItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { items: () => [] })
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
|
||||
function handleClick(item: SidebarNavItem) {
|
||||
if (item.path) router.push(item.path)
|
||||
item.onClick?.()
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet v-model:open="open">
|
||||
<SheetTrigger as-child>
|
||||
<button
|
||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||
:aria-label="t('common.nav.menu')"
|
||||
>
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" class="w-80 sm:w-96 overflow-y-auto">
|
||||
<ProfileSheetContent>
|
||||
<template v-if="props.items.length" #app-nav>
|
||||
<nav class="mt-4 space-y-1">
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
:class="[
|
||||
item.isActive?.()
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
]"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 shrink-0" />
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</nav>
|
||||
<Separator class="mt-4" />
|
||||
</template>
|
||||
</ProfileSheetContent>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
|
|
@ -3,9 +3,10 @@ import { computed } from 'vue'
|
|||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
||||
import { CalendarDays, Map, Heart, Search, Plus, Ticket, Megaphone } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
import type { SidebarNavItem } from '@/components/layout/StandaloneMenu.vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useEventsStore } from '@/modules/events/stores/events'
|
||||
import { useEvents } from '@/modules/events/composables/useEvents'
|
||||
|
|
@ -24,7 +25,9 @@ const { isAdmin, autoApprove } = useApprovalState()
|
|||
// Used to merge own LNbits drafts into the events feed right after
|
||||
// the user creates or edits an event — otherwise the new draft only
|
||||
// surfaces on the next EventsPage subscribe cycle.
|
||||
const { loadOwnEvents } = useEvents()
|
||||
// The hosting filter also lives on the events composable; the
|
||||
// sidebar entry below mirrors what the old in-page chip used to do.
|
||||
const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents()
|
||||
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
// Create lives in the bottom nav: when logged out, tapping it shows an
|
||||
|
|
@ -77,6 +80,31 @@ const tabs = computed<BottomTab[]>(() => [
|
|||
},
|
||||
])
|
||||
|
||||
// Sidebar entries shown to authed users only. "My tickets" routes to
|
||||
// the dedicated /my-tickets page; "Hosting" toggles the feed filter
|
||||
// (no dedicated page yet) and lands on /events so the user can
|
||||
// see the filtered list.
|
||||
const sidebarNav = computed<SidebarNavItem[]>(() => {
|
||||
if (!isAuthenticated.value) return []
|
||||
return [
|
||||
{
|
||||
name: t('events.filters.myTickets', 'My tickets'),
|
||||
icon: Ticket,
|
||||
path: '/my-tickets',
|
||||
isActive: () => route.path.startsWith('/my-tickets'),
|
||||
},
|
||||
{
|
||||
name: t('events.filters.hosting', 'Hosting'),
|
||||
icon: Megaphone,
|
||||
onClick: () => {
|
||||
if (!onlyHosting.value) toggleHosting()
|
||||
if (!route.path.startsWith('/events')) router.push('/events')
|
||||
},
|
||||
isActive: () => onlyHosting.value,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// Feed tab is active for the bare /events route AND all sub-paths that
|
||||
// aren't owned by another tab (e.g. /events/<id> detail pages).
|
||||
function isActive(path: string): boolean {
|
||||
|
|
@ -123,7 +151,7 @@ function handleDialogOpenChange(open: boolean) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive">
|
||||
<AppShell :tabs="tabs" :is-active="isActive" :sidebar-nav="sidebarNav">
|
||||
<CreateEventDialog
|
||||
:open="eventsStore.showCreateDialog"
|
||||
:event="eventsStore.editingEvent"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const messages: LocaleMessages = {
|
|||
profileDescription: 'Your Nostr identity and display name.',
|
||||
profileLoggedOutDescription: 'Sign in or change your preferences.',
|
||||
login: 'Log in',
|
||||
menu: 'Menu',
|
||||
backToHub: 'Back to hub',
|
||||
hub: 'Hub',
|
||||
theme: 'Theme',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const messages: LocaleMessages = {
|
|||
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
|
||||
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
|
||||
login: 'Iniciar sesión',
|
||||
menu: 'Menú',
|
||||
backToHub: 'Volver al hub',
|
||||
hub: 'Hub',
|
||||
theme: 'Tema',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const messages: LocaleMessages = {
|
|||
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
|
||||
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
|
||||
login: 'Se connecter',
|
||||
menu: 'Menu',
|
||||
backToHub: 'Retour au hub',
|
||||
hub: 'Hub',
|
||||
theme: 'Thème',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface LocaleMessages {
|
|||
profileDescription: string
|
||||
profileLoggedOutDescription: string
|
||||
login: string
|
||||
menu: string
|
||||
backToHub: string
|
||||
hub: string
|
||||
theme: string
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
||||
import { SlidersHorizontal, ChevronDown, History } from 'lucide-vue-next'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
|
|
@ -29,22 +28,16 @@ const {
|
|||
selectedCategories,
|
||||
hasActiveFilters,
|
||||
selectedDate,
|
||||
onlyOwnedTickets,
|
||||
onlyHosting,
|
||||
showPast,
|
||||
selectDate,
|
||||
setTemporal,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
toggleHosting,
|
||||
togglePast,
|
||||
resetFilters,
|
||||
subscribe,
|
||||
} = useEvents()
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const filtersOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -83,32 +76,11 @@ function handleSelectEvent(event: Event) {
|
|||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||
</div>
|
||||
|
||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||
"Hosting") narrow the feed to events the signed-in user
|
||||
has skin in and are hidden when logged out. The "Past events"
|
||||
chip is always visible since past-browsing doesn't require an
|
||||
account. -->
|
||||
<!-- Past-events filter chip. The role chips ("My tickets", "Hosting")
|
||||
used to live here; they now sit in the standalone sidebar menu.
|
||||
"Past events" stays inline since past-browsing doesn't require
|
||||
an account and pairs visually with the temporal filters above. -->
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<template v-if="isAuthenticated">
|
||||
<Button
|
||||
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="toggleOwnedTickets"
|
||||
>
|
||||
<Ticket class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="onlyHosting ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="toggleHosting"
|
||||
>
|
||||
<Megaphone class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.hosting', 'Hosting') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
:variant="showPast ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue