feat(activities): UI tweaks across feed, detail, hosting, calendar, scan, shell #91

Merged
padreug merged 25 commits from feat/ui-tweaks into dev 2026-06-10 16:35:50 +00:00
11 changed files with 135 additions and 79 deletions
Showing only changes of commit d871093168 - Show all commits

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.
Padreug 2026-06-04 21:51:45 +02:00 committed by padreug

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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

View 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>

View file

@ -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"

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -21,6 +21,7 @@ export interface LocaleMessages {
profileDescription: string
profileLoggedOutDescription: string
login: string
menu: string
backToHub: string
hub: string
theme: string

View file

@ -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"