feat(layout): unified app-shell primitives (Phase A, no consumer changes)
Build the shared building blocks for the unified bottom-nav UX across the hub + 7 standalones. Phase A is groundwork only — no App.vue or Hub.vue consumer is wired up yet, so this commit is purely additive. New components in src/components/layout/: - PreferencesRow.vue theme/language/currency triad (row + list layouts) - ProfileSheetContent.vue identity card + back-to-hub + prefs + ProfileSettings - ProfileSheetTrigger.vue bottom-row Profile button → opens sheet - HubPill.vue fixed top-right back-to-hub link - BottomNav.vue consumer tabs + appended Profile slot - AppShell.vue outer wrapper composing the above New composable: useCurrentUserAvatar — picture/displayName/fallbackInitial from the auth user object. i18n: new common.nav.* namespace in en/es/fr (typed via LocaleMessages). Env: VITE_HUB_ROOT_URL added to .env.example with path/subdomain/local guidance — consumed by HubPill and the back-to-hub sheet item. Phase B (consumer refactor: chat/wallet/tasks first, then forum/libra/ market/activities, then hub) lands separately.
This commit is contained in:
parent
0a0769115b
commit
eaacb3b985
12 changed files with 582 additions and 3 deletions
19
.env.example
19
.env.example
|
|
@ -87,6 +87,25 @@ VITE_HUB_FORUM_URL=
|
||||||
VITE_HUB_MARKET_URL=
|
VITE_HUB_MARKET_URL=
|
||||||
VITE_HUB_TASKS_URL=
|
VITE_HUB_TASKS_URL=
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# VITE_HUB_ROOT_URL — standalone → hub (the inverse of the URLs above)
|
||||||
|
#
|
||||||
|
# Read by the standalone shell's <HubPill> (top-right "back to hub" link)
|
||||||
|
# and the "Back to Hub" item inside the profile sheet. Each standalone's
|
||||||
|
# bundle gets this value baked in at build time.
|
||||||
|
#
|
||||||
|
# In PATH-MODE deployment the standalone and hub share an origin, so the
|
||||||
|
# default ('/' if unset) is correct — the link is same-origin.
|
||||||
|
#
|
||||||
|
# In SUBDOMAIN-MODE production the standalone is on a different origin
|
||||||
|
# than the hub; set this to the full hub URL:
|
||||||
|
# VITE_HUB_ROOT_URL=https://app.example.com/
|
||||||
|
#
|
||||||
|
# In LOCAL DEV with `npm run dev:all`, the hub is on :5173:
|
||||||
|
# VITE_HUB_ROOT_URL=http://localhost:5173/
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
VITE_HUB_ROOT_URL=
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
# VITE_BASE_PATH — build-time only, NOT per .env
|
# VITE_BASE_PATH — build-time only, NOT per .env
|
||||||
#
|
#
|
||||||
|
|
|
||||||
55
src/components/layout/AppShell.vue
Normal file
55
src/components/layout/AppShell.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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). */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
hideHub: false,
|
||||||
|
loggedOutOpensSheet: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
useTheme()
|
||||||
|
|
||||||
|
/** Login page renders without nav chrome to avoid competing with the form. */
|
||||||
|
const isLoginPage = computed(() => route.path === '/login')
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<BottomNav
|
||||||
|
v-if="!isLoginPage"
|
||||||
|
:tabs="props.tabs"
|
||||||
|
:is-active="props.isActive"
|
||||||
|
:logged-out-opens-sheet="props.loggedOutOpensSheet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HubPill v-if="!props.hideHub && !isLoginPage" />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
65
src/components/layout/BottomNav.vue
Normal file
65
src/components/layout/BottomNav.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<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. */
|
||||||
|
name: string
|
||||||
|
/** lucide-vue-next icon component. */
|
||||||
|
icon: Component
|
||||||
|
/** Router path to push on click. */
|
||||||
|
path: string
|
||||||
|
/** Optional unread/cart badge count. Falsy values hide the badge. */
|
||||||
|
badge?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tabs: BottomTab[]
|
||||||
|
/** 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 router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
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">
|
||||||
|
<!-- App-specific tabs (consumer-provided) -->
|
||||||
|
<button
|
||||||
|
v-for="tab in props.tabs"
|
||||||
|
:key="tab.path"
|
||||||
|
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||||
|
:class="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)"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-5 h-5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="tab.badge"
|
||||||
|
class="absolute top-1.5 right-3 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"
|
||||||
|
>
|
||||||
|
{{ 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>
|
||||||
24
src/components/layout/HubPill.vue
Normal file
24
src/components/layout/HubPill.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<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>
|
||||||
152
src/components/layout/PreferencesRow.vue
Normal file
152
src/components/layout/PreferencesRow.vue
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Sun, Moon, Monitor, Globe, Coins } from 'lucide-vue-next'
|
||||||
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
|
import { useTheme } from '@/components/theme-provider'
|
||||||
|
import { useLocale } from '@/composables/useLocale'
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup, DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel, DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav).
|
||||||
|
* 'list' = three full-width list rows (used inside the profile sheet). */
|
||||||
|
layout?: 'row' | 'list'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { layout: 'row' })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { theme, setTheme, currentTheme } = useTheme()
|
||||||
|
const { currentLocale, locales, setLocale } = useLocale()
|
||||||
|
|
||||||
|
const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun))
|
||||||
|
const currentLocaleLabel = computed(
|
||||||
|
() => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value
|
||||||
|
)
|
||||||
|
|
||||||
|
// Currency picker is intentionally still a placeholder until #45 lands —
|
||||||
|
// the row UX is what we're building here, not the underlying preference.
|
||||||
|
function notImplemented() {
|
||||||
|
toast.info(t('common.nav.currencyComingSoon'), {
|
||||||
|
description: t('common.nav.currencyComingSoonDescription'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Row layout: three icon-stacked buttons side-by-side (Hub bottom nav). -->
|
||||||
|
<div v-if="props.layout === 'row'" class="contents">
|
||||||
|
<!-- 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="ThemeIcon" class="w-5 h-5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ t('common.nav.theme') }}</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" class="w-40">
|
||||||
|
<DropdownMenuLabel>{{ t('common.nav.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" />{{ t('common.nav.themeLight') }}</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />{{ t('common.nav.themeDark') }}</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />{{ t('common.nav.themeSystem') }}</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">{{ t('common.nav.language') }}</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" class="w-44">
|
||||||
|
<DropdownMenuLabel>{{ t('common.nav.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, gated on #45) -->
|
||||||
|
<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">{{ t('common.nav.currency') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List layout: three full-width rows (profile sheet). -->
|
||||||
|
<div v-else class="flex flex-col">
|
||||||
|
<!-- Theme -->
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<component :is="ThemeIcon" class="w-5 h-5 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('common.nav.theme') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>{{ t(`common.nav.theme${theme.charAt(0).toUpperCase()}${theme.slice(1)}`) }}</span>
|
||||||
|
<ChevronRight class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-40">
|
||||||
|
<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" />{{ t('common.nav.themeLight') }}</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />{{ t('common.nav.themeDark') }}</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />{{ t('common.nav.themeSystem') }}</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Globe class="w-5 h-5 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('common.nav.language') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>{{ currentLocaleLabel }}</span>
|
||||||
|
<ChevronRight class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-44">
|
||||||
|
<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, gated on #45) -->
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left opacity-60"
|
||||||
|
@click="notImplemented"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Coins class="w-5 h-5 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('common.nav.currency') }}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
86
src/components/layout/ProfileSheetContent.vue
Normal file
86
src/components/layout/ProfileSheetContent.vue
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Home, LogIn } from 'lucide-vue-next'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||||
|
import PreferencesRow from './PreferencesRow.vue'
|
||||||
|
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated, user } = useAuth()
|
||||||
|
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||||
|
|
||||||
|
const npubPreview = computed(() => {
|
||||||
|
const pubkey = user.value?.pubkey
|
||||||
|
if (!pubkey) return ''
|
||||||
|
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||||
|
<SheetDescription v-if="isAuthenticated">
|
||||||
|
{{ t('common.nav.profileDescription') }}
|
||||||
|
</SheetDescription>
|
||||||
|
<SheetDescription v-else>
|
||||||
|
{{ t('common.nav.profileLoggedOutDescription') }}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<!-- Identity card (logged in) -->
|
||||||
|
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
|
||||||
|
<Avatar class="h-12 w-12">
|
||||||
|
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||||
|
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cross-app links + global preferences (always visible, auth or not) -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
:href="hubRootUrl"
|
||||||
|
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors"
|
||||||
|
:aria-label="t('common.nav.backToHub')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Home class="w-5 h-5 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<PreferencesRow layout="list" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
|
||||||
|
<div v-if="!isAuthenticated" class="mt-6">
|
||||||
|
<Separator class="mb-4" />
|
||||||
|
<Button class="w-full" @click="goLogin">
|
||||||
|
<LogIn class="mr-2 h-4 w-4" />
|
||||||
|
{{ t('common.nav.login') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logged-in: full profile management form -->
|
||||||
|
<div v-else class="mt-6">
|
||||||
|
<Separator class="mb-4" />
|
||||||
|
<ProfileSettings />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
68
src/components/layout/ProfileSheetTrigger.vue
Normal file
68
src/components/layout/ProfileSheetTrigger.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { LogIn, User as UserIcon } from 'lucide-vue-next'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import ProfileSheetContent from './ProfileSheetContent.vue'
|
||||||
|
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** When true (Hub bottom row), the unauthenticated state still opens the
|
||||||
|
* sheet so logged-out users can change theme/lang/currency. When false
|
||||||
|
* (standalone bottom rows), the unauth state routes to /login directly —
|
||||||
|
* the standalone has nothing useful to show until you're authed. */
|
||||||
|
loggedOutOpensSheet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated, pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||||
|
const open = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Authed OR (unauth and we want sheet open) → wrap button in <Sheet>. -->
|
||||||
|
<Sheet v-if="isAuthenticated || props.loggedOutOpensSheet" v-model:open="open">
|
||||||
|
<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"
|
||||||
|
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.preferences')"
|
||||||
|
>
|
||||||
|
<template v-if="isAuthenticated">
|
||||||
|
<Avatar class="w-6 h-6">
|
||||||
|
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||||
|
<AvatarFallback class="text-[10px]">
|
||||||
|
<template v-if="fallbackInitial">{{ fallbackInitial }}</template>
|
||||||
|
<UserIcon v-else class="w-4 h-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span class="text-[10px] font-medium">{{ t('common.nav.profile') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UserIcon class="w-5 h-5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ t('common.nav.profile') }}</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
|
||||||
|
<ProfileSheetContent />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<!-- Unauth + standalone shell → bypass sheet, jump straight to /login. -->
|
||||||
|
<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"
|
||||||
|
:aria-label="t('common.nav.login')"
|
||||||
|
@click="router.push('/login')"
|
||||||
|
>
|
||||||
|
<LogIn class="w-5 h-5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ t('common.nav.login') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
42
src/composables/useCurrentUserAvatar.ts
Normal file
42
src/composables/useCurrentUserAvatar.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface the current user's display identity in the form needed by avatar
|
||||||
|
* components: a `picture` URL (Nostr kind-0 metadata, mirrored into LNbits
|
||||||
|
* `extra.picture` by ProfileSettings on save), a friendly display name, and
|
||||||
|
* a single-character fallback for `<AvatarFallback>` when no picture loads.
|
||||||
|
*
|
||||||
|
* Returns null-ish values when unauthenticated so consumers can render a
|
||||||
|
* generic icon (LogIn, UserIcon) without optional-chain noise.
|
||||||
|
*/
|
||||||
|
export function useCurrentUserAvatar() {
|
||||||
|
const { user, isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const pictureUrl = computed<string | null>(() => {
|
||||||
|
if (!isAuthenticated.value) return null
|
||||||
|
return user.value?.extra?.picture || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayName = computed<string | null>(() => {
|
||||||
|
if (!isAuthenticated.value) return null
|
||||||
|
return user.value?.extra?.display_name || user.value?.username || null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** First non-whitespace character of display name, uppercased. Empty when
|
||||||
|
* unauthenticated or no name available — consumer should render a fallback
|
||||||
|
* icon in that case. */
|
||||||
|
const fallbackInitial = computed<string>(() => {
|
||||||
|
const name = displayName.value
|
||||||
|
if (!name) return ''
|
||||||
|
const trimmed = name.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed.charAt(0).toUpperCase() : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
pictureUrl,
|
||||||
|
displayName,
|
||||||
|
fallbackInitial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,24 @@ const messages: LocaleMessages = {
|
||||||
common: {
|
common: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
error: 'An error occurred',
|
error: 'An error occurred',
|
||||||
success: 'Operation successful'
|
success: 'Operation successful',
|
||||||
|
nav: {
|
||||||
|
profile: 'Profile',
|
||||||
|
preferences: 'Preferences',
|
||||||
|
profileDescription: 'Your Nostr identity and display name.',
|
||||||
|
profileLoggedOutDescription: 'Sign in or change your preferences.',
|
||||||
|
login: 'Log in',
|
||||||
|
backToHub: 'Back to hub',
|
||||||
|
hub: 'Hub',
|
||||||
|
theme: 'Theme',
|
||||||
|
themeLight: 'Light',
|
||||||
|
themeDark: 'Dark',
|
||||||
|
themeSystem: 'System',
|
||||||
|
language: 'Language',
|
||||||
|
currency: 'Currency',
|
||||||
|
currencyComingSoon: 'Currency picker — coming soon',
|
||||||
|
currencyComingSoonDescription: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
notFound: 'Page not found',
|
notFound: 'Page not found',
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,24 @@ const messages: LocaleMessages = {
|
||||||
common: {
|
common: {
|
||||||
loading: 'Cargando...',
|
loading: 'Cargando...',
|
||||||
error: 'Ha ocurrido un error',
|
error: 'Ha ocurrido un error',
|
||||||
success: 'Operación exitosa'
|
success: 'Operación exitosa',
|
||||||
|
nav: {
|
||||||
|
profile: 'Perfil',
|
||||||
|
preferences: 'Preferencias',
|
||||||
|
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
|
||||||
|
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
|
||||||
|
login: 'Iniciar sesión',
|
||||||
|
backToHub: 'Volver al hub',
|
||||||
|
hub: 'Hub',
|
||||||
|
theme: 'Tema',
|
||||||
|
themeLight: 'Claro',
|
||||||
|
themeDark: 'Oscuro',
|
||||||
|
themeSystem: 'Sistema',
|
||||||
|
language: 'Idioma',
|
||||||
|
currency: 'Moneda',
|
||||||
|
currencyComingSoon: 'Selector de moneda — próximamente',
|
||||||
|
currencyComingSoonDescription: 'Un ajuste de moneda preferida (sats/USD/EUR) está en la hoja de ruta.'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
notFound: 'Página no encontrada',
|
notFound: 'Página no encontrada',
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,24 @@ const messages: LocaleMessages = {
|
||||||
common: {
|
common: {
|
||||||
loading: 'Chargement...',
|
loading: 'Chargement...',
|
||||||
error: 'Une erreur est survenue',
|
error: 'Une erreur est survenue',
|
||||||
success: 'Opération réussie'
|
success: 'Opération réussie',
|
||||||
|
nav: {
|
||||||
|
profile: 'Profil',
|
||||||
|
preferences: 'Préférences',
|
||||||
|
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
|
||||||
|
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
|
||||||
|
login: 'Se connecter',
|
||||||
|
backToHub: 'Retour au hub',
|
||||||
|
hub: 'Hub',
|
||||||
|
theme: 'Thème',
|
||||||
|
themeLight: 'Clair',
|
||||||
|
themeDark: 'Sombre',
|
||||||
|
themeSystem: 'Système',
|
||||||
|
language: 'Langue',
|
||||||
|
currency: 'Devise',
|
||||||
|
currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
|
||||||
|
currencyComingSoonDescription: 'Un réglage de devise préférée (sats/USD/EUR) est prévu.'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
notFound: 'Page non trouvée',
|
notFound: 'Page non trouvée',
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,23 @@ export interface LocaleMessages {
|
||||||
loading: string
|
loading: string
|
||||||
error: string
|
error: string
|
||||||
success: string
|
success: string
|
||||||
|
nav: {
|
||||||
|
profile: string
|
||||||
|
preferences: string
|
||||||
|
profileDescription: string
|
||||||
|
profileLoggedOutDescription: string
|
||||||
|
login: string
|
||||||
|
backToHub: string
|
||||||
|
hub: string
|
||||||
|
theme: string
|
||||||
|
themeLight: string
|
||||||
|
themeDark: string
|
||||||
|
themeSystem: string
|
||||||
|
language: string
|
||||||
|
currency: string
|
||||||
|
currencyComingSoon: string
|
||||||
|
currencyComingSoonDescription: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
errors: {
|
errors: {
|
||||||
notFound: string
|
notFound: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue