feat(layout): adopt unified AppShell across hub + 7 standalones (Phase B) #52

Merged
padreug merged 2 commits from feat/unified-app-shell-consumers into main 2026-05-07 10:37:25 +00:00
13 changed files with 142 additions and 576 deletions
Showing only changes of commit c80a8461ac - Show all commits

feat(layout): adopt unified AppShell across hub + 7 standalones (Phase B)

Refactor every entry point to consume the Phase A primitives. Each App.vue
collapses from 47-127 lines of shell boilerplate into a thin AppShell
consumer that declares its own BottomTab[] and active-path matcher;
Hub.vue now reuses ProfileSheetTrigger + PreferencesRow instead of
inlining its own bottom row.

The Settings tab is dropped from activities and libra (theme/lang/currency
now live in the shared profile sheet — see #50 for cleanup of orphaned
SettingsPage.vue routes). The redundant top-right LogIn icon is dropped
from every standalone (the bottom-nav profile slot covers that affordance).

BottomNav.vue gains optional `onClick`, `disabled`, and an optional `path`
on BottomTab so consumers can express coming-soon toasts (forum's Spaces/
Search/Alerts) and auth-gated tabs (market's "My Store" → toast when
logged out) without reaching back into shell internals.

While in here: remove the page-level Refresh buttons in ActivitiesPage,
EventsPage, MyTicketsPage, and NostrFeed. The relay-subscription +
VisibilityService reconnect path keeps these views fresh; manual refresh
was redundant and was now visually colliding with the new top-right
HubPill.

Net: -434 lines.
Padreug 2026-05-07 12:26:26 +02:00

View file

@ -1,94 +1,29 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { Button } from '@/components/ui/button'
import {
PlusCircle, List, Scale, Wallet, Settings, LogIn,
} from 'lucide-vue-next'
import { PlusCircle, List, Scale, Wallet } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
// Bottom navigation tabs
const bottomTabs = computed(() => [
// Settings dropped theme/lang/currency now live in the shared profile sheet.
const tabs = computed<BottomTab[]>(() => [
{ name: t('libra.nav.record'), icon: PlusCircle, path: '/record' },
{ name: t('libra.nav.transactions'), icon: List, path: '/expenses/transactions' },
{ name: t('libra.nav.balance'), icon: Scale, path: '/balance' },
{ name: t('libra.nav.wallet'), icon: Wallet, path: '/wallet' },
{ name: t('libra.nav.settings'), icon: Settings, path: '/settings' },
])
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(path: string): boolean {
if (path === '/record') {
return route.path === '/record'
}
if (path === '/expenses/transactions') {
return route.path === '/expenses/transactions'
}
function isActive(path: string): boolean {
if (path === '/record') return route.path === '/record'
if (path === '/expenses/transactions') return route.path === '/expenses/transactions'
return route.path.startsWith(path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Top bar with login -->
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
<LogIn class="w-4 h-4" />
</Button>
</div>
<!-- Main content (with bottom padding for nav bar) -->
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
<router-view />
</main>
<!-- Bottom navigation bar -->
<nav
v-if="!isLoginPage"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in bottomTabs"
:key="tab.path"
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="isActiveTab(tab.path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'"
@click="router.push(tab.path)"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ tab.name }}</span>
</button>
</div>
</nav>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -1,84 +1,38 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import {
CalendarDays, Map, Heart, Settings, Search,
} from 'lucide-vue-next'
import { CalendarDays, Map, Heart, Search } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
useTheme()
const showLoginDialog = ref(false)
// Bottom navigation tabs (p'a semana style)
const bottomTabs = computed(() => [
// Settings dropped theme/lang/currency now live in the shared profile sheet.
const tabs = computed<BottomTab[]>(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
{ name: t('activities.nav.settings'), icon: Settings, path: '/settings' },
])
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(path: string): boolean {
// Feed tab is active for the bare /activities route AND all sub-paths that
// aren't owned by another tab (e.g. /activities/<id> detail pages).
function isActive(path: string): boolean {
if (path === '/activities') {
return route.path === '/activities' || route.path.startsWith('/activities/') &&
return (
route.path === '/activities' ||
(route.path.startsWith('/activities/') &&
!route.path.startsWith('/activities/calendar') &&
!route.path.startsWith('/activities/map') &&
!route.path.startsWith('/activities/favorites')
!route.path.startsWith('/activities/favorites'))
)
}
return route.path.startsWith(path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Main content (with bottom padding for nav bar) -->
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
<router-view />
</main>
<!-- Bottom navigation bar (p'a semana style) -->
<nav
v-if="!isLoginPage"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in bottomTabs"
:key="tab.path"
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="isActiveTab(tab.path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'"
@click="router.push(tab.path)"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ tab.name }}</span>
</button>
</div>
</nav>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -1,47 +1,16 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { Button } from '@/components/ui/button'
import { LogIn } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
const route = useRoute()
const router = useRouter()
// Chat owns its in-page navigation today; the shell only contributes the
// always-on Profile entry on the right of the bottom row.
const tabs: BottomTab[] = []
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
function isActive(_path: string): boolean {
return false
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
<LogIn class="w-4 h-4" />
</Button>
</div>
<main class="flex-1">
<router-view />
</main>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -8,10 +8,16 @@ export interface BottomTab {
name: string
/** lucide-vue-next icon component. */
icon: Component
/** Router path to push on click. */
path: string
/** Router path to push on click. Optional — coming-soon entries omit it. */
path?: string
/** Optional unread/cart badge count. Falsy values hide the badge. */
badge?: number | null
/** Click override. When provided, replaces the default router.push(path).
* Consumers use this for coming-soon toasts or auth-gated CTAs. */
onClick?: () => void
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
* for auth-required tabs when the user is logged out. */
disabled?: boolean
}
interface Props {
@ -28,6 +34,14 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
const router = useRouter()
function onTabClick(tab: BottomTab) {
if (tab.onClick) {
tab.onClick()
return
}
if (tab.path) router.push(tab.path)
}
</script>
<template>
@ -39,13 +53,16 @@ const router = useRouter()
<!-- App-specific tabs (consumer-provided) -->
<button
v-for="tab in props.tabs"
:key="tab.path"
:key="tab.name"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="props.isActive(tab.path)
:class="[
tab.path && props.isActive(tab.path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'"
:aria-current="props.isActive(tab.path) ? 'page' : undefined"
@click="router.push(tab.path)"
: 'text-muted-foreground hover:text-foreground',
tab.disabled ? 'opacity-50' : '',
]"
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
@click="onTabClick(tab)"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ tab.name }}</span>

View file

@ -1,104 +1,35 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { Button } from '@/components/ui/button'
import {
LogIn, Newspaper, Hash, SquarePen, Search, Bell,
} from 'lucide-vue-next'
import { Newspaper, Hash, SquarePen, Search, Bell } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
const route = useRoute()
const router = useRouter()
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
interface Tab {
name: string
icon: any
path?: string
comingSoon?: { issue: number; label: string }
}
const bottomTabs: Tab[] = [
{ name: 'Posts', icon: Newspaper, path: '/forum' },
{ name: 'Spaces', icon: Hash, comingSoon: { issue: 31, label: 'Spaces' } },
{ name: 'Submit', icon: SquarePen, path: '/submit' },
{ name: 'Search', icon: Search, comingSoon: { issue: 15, label: 'Search' } },
{ name: 'Alerts', icon: Bell, comingSoon: { issue: 32, label: 'Notifications' } },
]
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(tab: Tab): boolean {
if (!tab.path) return false
if (tab.path === '/forum') return route.path === '/forum' || route.path.startsWith('/submission/')
return route.path.startsWith(tab.path)
}
function onTabClick(tab: Tab) {
if (tab.path) {
router.push(tab.path)
} else if (tab.comingSoon) {
toast.info(`${tab.comingSoon.label} — coming soon`, {
description: `Tracked on issue #${tab.comingSoon.issue}`,
function comingSoon(label: string, issue: number) {
toast.info(`${label} — coming soon`, {
description: `Tracked on issue #${issue}`,
})
}
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
const tabs = computed<BottomTab[]>(() => [
{ name: 'Posts', icon: Newspaper, path: '/forum' },
{ name: 'Spaces', icon: Hash, disabled: true, onClick: () => comingSoon('Spaces', 31) },
{ name: 'Submit', icon: SquarePen, path: '/submit' },
{ name: 'Search', icon: Search, disabled: true, onClick: () => comingSoon('Search', 15) },
{ name: 'Alerts', icon: Bell, disabled: true, onClick: () => comingSoon('Notifications', 32) },
])
function isActive(path: string): boolean {
if (path === '/forum') {
return route.path === '/forum' || route.path.startsWith('/submission/')
}
return route.path.startsWith(path)
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
<LogIn class="w-4 h-4" />
</Button>
</div>
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
<router-view />
</main>
<nav
v-if="!isLoginPage"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in bottomTabs"
:key="tab.name"
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[
isActiveTab(tab)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
tab.comingSoon ? 'opacity-50' : '',
]"
@click="onTabClick(tab)"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ tab.name }}</span>
</button>
</div>
</nav>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -1,114 +1,55 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import { Store, ShoppingCart, Package } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService'
import { useMarketStore } from '@/modules/market/stores/market'
import {
Store, ShoppingCart, Package, LogIn, User as UserIcon,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
useTheme()
const { isAuthenticated } = useAuth()
const marketStore = useMarketStore()
const showLoginDialog = ref(false)
interface Tab {
name: string
icon: any
path?: string
authRequired?: boolean
badge?: () => number
onClick?: () => void
}
const bottomTabs = computed<Tab[]>(() => [
const tabs = computed<BottomTab[]>(() => [
{ name: 'Browse', icon: Store, path: '/market' },
{ name: 'Cart', icon: ShoppingCart, path: '/cart', badge: () => marketStore.totalCartItems },
{ name: 'My Store', icon: Package, path: '/market/dashboard', authRequired: true },
isAuthenticated.value
? { name: 'Profile', icon: UserIcon, path: '/profile' }
: { name: 'Log in', icon: LogIn, path: '/login' },
{
name: 'Cart',
icon: ShoppingCart,
path: '/cart',
badge: marketStore.totalCartItems || null,
},
{
name: 'My Store',
icon: Package,
path: isAuthenticated.value ? '/market/dashboard' : undefined,
disabled: !isAuthenticated.value,
onClick: !isAuthenticated.value
? () =>
toast.info('My Store requires login', {
action: { label: 'Log in', onClick: () => router.push('/login') },
})
: undefined,
},
])
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(tab: Tab): boolean {
if (!tab.path) return false
if (tab.path === '/market') {
return route.path === '/market' || route.path.startsWith('/market/stall/') || route.path.startsWith('/market/product/')
function isActive(path: string): boolean {
if (path === '/market') {
return (
route.path === '/market' ||
route.path.startsWith('/market/stall/') ||
route.path.startsWith('/market/product/')
)
}
if (tab.path === '/cart') return route.path === '/cart' || route.path.startsWith('/checkout/')
return route.path.startsWith(tab.path)
}
function onTabClick(tab: Tab) {
if (tab.authRequired && !isAuthenticated.value) {
toast.info(`${tab.name} requires login`, {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
return
if (path === '/cart') {
return route.path === '/cart' || route.path.startsWith('/checkout/')
}
if (tab.path) router.push(tab.path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
return route.path.startsWith(path)
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
<router-view />
</main>
<nav
v-if="!isLoginPage"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in bottomTabs"
:key="tab.name"
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[
isActiveTab(tab)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
tab.authRequired && !isAuthenticated ? 'opacity-50' : '',
]"
@click="onTabClick(tab)"
>
<span class="relative inline-flex">
<component :is="tab.icon" class="w-5 h-5" />
<span
v-if="tab.badge && tab.badge() > 0"
class="absolute -top-1.5 -right-2 min-w-[16px] h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold leading-4 text-center"
>{{ tab.badge() }}</span>
</span>
<span class="text-[10px] font-medium">{{ tab.name }}</span>
</button>
</div>
</nav>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { RefreshCw, SlidersHorizontal, ChevronDown, Plus, LogIn } from 'lucide-vue-next'
import { SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService'
import { useActivities } from '../composables/useActivities'
import CreateEventDialog from '../components/CreateEventDialog.vue'
@ -86,18 +86,6 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
<Plus class="w-4 h-4 mr-1.5" />
<span class="hidden sm:inline">{{ t('activities.createNew') }}</span>
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="handleRefresh" :disabled="isLoading">
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
</Button>
<Button
v-if="!isAuthenticated"
variant="ghost"
size="icon"
class="h-8 w-8"
@click="$router.push('/login')"
>
<LogIn class="w-4 h-4" />
</Button>
</div>
</div>

View file

@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import CreateEventDialog from '../components/CreateEventDialog.vue'
import { RefreshCw, User, LogIn, Plus } from 'lucide-vue-next'
import { User, LogIn, Plus } from 'lucide-vue-next'
import { formatEventPrice } from '@/lib/utils/formatting'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
@ -84,10 +84,6 @@ function handleEventCreated() {
<Plus class="w-4 h-4" />
<span class="ml-2">Create Event</span>
</Button>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading" class="flex-1 sm:flex-none">
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
<span class="ml-2">Refresh</span>
</Button>
</div>
</div>

View file

@ -131,10 +131,6 @@ onMounted(async () => {
<span>Please log in to view your tickets</span>
</div>
</div>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
<span v-if="isLoading" class="animate-spin mr-2"></span>
Refresh
</Button>
</div>
<div v-if="!isAuthenticated" class="text-center py-12">

View file

@ -465,16 +465,6 @@ function cancelDelete() {
<p class="text-xs md:text-sm text-muted-foreground">{{ feedDescription }}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
<span class="hidden md:inline">Refresh</span>
</Button>
</div>
</div>

View file

@ -1,29 +1,17 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuthService'
import { useTheme } from '@/components/theme-provider'
import { useLocale } from '@/composables/useLocale'
import { toast } from 'vue-sonner'
import {
Scale, ListTodo, Newspaper, MessageCircle, Wallet, CalendarDays,
Store, UtensilsCrossed,
User as UserIcon, LogIn, Sun, Moon, Monitor, Globe, Coins,
} from 'lucide-vue-next'
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuLabel, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger,
} from '@/components/ui/sheet'
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
import ProfileSheetTrigger from '@/components/layout/ProfileSheetTrigger.vue'
import PreferencesRow from '@/components/layout/PreferencesRow.vue'
const router = useRouter()
const { isAuthenticated } = useAuth()
const { theme, setTheme, currentTheme } = useTheme()
const { currentLocale, locales, setLocale } = useLocale()
interface Module {
label: string
@ -85,14 +73,6 @@ function onTileClick(m: Module, event: Event) {
}
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
}
const showProfile = ref(false)
function notImplemented() {
toast.info('Currency picker — coming soon', {
description: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.',
})
}
</script>
<template>
@ -154,85 +134,17 @@ function notImplemented() {
</div>
</div>
<!-- Bottom bar: profile & user preferences -->
<!-- Bottom bar: profile + user preferences. Profile entry is identical to
every standalone (avatar from kind-0, sheet has back-to-hub stub
removed since this IS the hub). Logged-out users still get the
sheet so they can change theme/lang without signing in. -->
<nav
class="relative z-10 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<!-- Profile (when logged in) / Log in (when not) -->
<Sheet v-if="isAuthenticated" v-model:open="showProfile">
<SheetTrigger as-child>
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
<UserIcon class="w-5 h-5" />
<span class="text-[10px] font-medium">Profile</span>
</button>
</SheetTrigger>
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
<SheetHeader>
<SheetTitle>Profile</SheetTitle>
<SheetDescription>Your Nostr identity and display name.</SheetDescription>
</SheetHeader>
<div class="mt-4">
<ProfileSettings />
</div>
</SheetContent>
</Sheet>
<button
v-else
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors"
@click="router.push('/login')"
>
<LogIn class="w-5 h-5" />
<span class="text-[10px] font-medium">Log in</span>
</button>
<!-- Theme -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
<component :is="currentTheme === 'dark' ? Moon : Sun" class="w-5 h-5" />
<span class="text-[10px] font-medium">Theme</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="w-40">
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup :model-value="theme" @update:model-value="(v: string) => setTheme(v as 'dark' | 'light' | 'system')">
<DropdownMenuRadioItem value="light"><Sun class="w-4 h-4 mr-2" />Light</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />Dark</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />System</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<!-- Language -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
<Globe class="w-5 h-5" />
<span class="text-[10px] font-medium">Language</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="w-44">
<DropdownMenuLabel>Language</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup :model-value="currentLocale" @update:model-value="(v: string) => setLocale(v)">
<DropdownMenuRadioItem v-for="l in locales" :key="l.code" :value="l.code">
<span class="mr-2">{{ l.flag }}</span>{{ l.name }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<!-- Currency (placeholder) -->
<button
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors opacity-50"
@click="notImplemented"
>
<Coins class="w-5 h-5" />
<span class="text-[10px] font-medium">Currency</span>
</button>
<ProfileSheetTrigger :logged-out-opens-sheet="true" />
<PreferencesRow layout="row" />
</div>
</nav>
</div>

View file

@ -1,47 +1,16 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { Button } from '@/components/ui/button'
import { LogIn } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
const route = useRoute()
const router = useRouter()
// Tasks owns its in-page navigation; the shell only contributes the
// always-on Profile entry on the right of the bottom row.
const tabs: BottomTab[] = []
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
function isActive(_path: string): boolean {
return false
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
<LogIn class="w-4 h-4" />
</Button>
</div>
<main class="flex-1">
<router-view />
</main>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -1,48 +1,16 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { Button } from '@/components/ui/button'
import { LogIn } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
const route = useRoute()
const router = useRouter()
// Wallet owns its in-page navigation; the shell only contributes the
// always-on Profile entry on the right of the bottom row.
const tabs: BottomTab[] = []
useTheme()
const { isAuthenticated } = useAuth()
const showLoginDialog = ref(false)
const isLoginPage = computed(() => route.path === '/login')
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
function isActive(_path: string): boolean {
return false
}
</script>
<template>
<div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Top bar with login -->
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
<LogIn class="w-4 h-4" />
</Button>
</div>
<main class="flex-1">
<router-view />
</main>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>