Compare commits
No commits in common. "ef042fed71cc95de2c086ca394f4d5927d25eabf" and "0a0769115bc2967f69154ab12c8043d738599953" have entirely different histories.
ef042fed71
...
0a0769115b
28 changed files with 628 additions and 724 deletions
19
.env.example
19
.env.example
|
|
@ -87,25 +87,6 @@ VITE_HUB_FORUM_URL=
|
|||
VITE_HUB_MARKET_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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,29 +1,94 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { PlusCircle, List, Scale, Wallet } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Bottom navigation tabs
|
||||
const bottomTabs = computed(() => [
|
||||
{ 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' },
|
||||
])
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/record') return route.path === '/record'
|
||||
if (path === '/expenses/transactions') return route.path === '/expenses/transactions'
|
||||
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'
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|||
import { useToast } from '@/core/composables/useToast'
|
||||
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
|
||||
import type { Transaction } from '@/modules/expenses/types'
|
||||
import { ArrowDown, ArrowUp, Clock, Loader2, PieChart } from 'lucide-vue-next'
|
||||
import { ArrowDown, ArrowUp, Clock, RefreshCw, Loader2, PieChart } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -19,6 +20,7 @@ const balance = ref<number | null>(null)
|
|||
const balanceCurrency = ref<string>('sats')
|
||||
const pendingTransactions = ref<Transaction[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||
const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true')
|
||||
|
|
@ -64,6 +66,12 @@ async function loadData() {
|
|||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
isRefreshing.value = true
|
||||
await loadData()
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
await loadData()
|
||||
|
|
@ -86,8 +94,18 @@ function formatFiat(amount: number, currency: string): string {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<div class="mb-6">
|
||||
<!-- Header with refresh -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">{{ t('libra.balance.title') }}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="isRefreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isRefreshing }" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
|
|
|
|||
|
|
@ -1,38 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarDays, Map, Heart, Search } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
useTheme()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Bottom navigation tabs (p'a semana style)
|
||||
const bottomTabs = computed(() => [
|
||||
{ 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' },
|
||||
])
|
||||
|
||||
// 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 {
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(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>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
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'
|
||||
|
||||
// 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[] = []
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<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. 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 {
|
||||
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()
|
||||
|
||||
function onTabClick(tab: BottomTab) {
|
||||
if (tab.onClick) {
|
||||
tab.onClick()
|
||||
return
|
||||
}
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
</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.name"
|
||||
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
tab.path && props.isActive(tab.path)
|
||||
? 'text-primary'
|
||||
: '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>
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 { Newspaper, Hash, SquarePen, Search, Bell } 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 { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LogIn, Newspaper, Hash, SquarePen, Search, Bell,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function comingSoon(label: string, issue: number) {
|
||||
toast.info(`${label} — coming soon`, {
|
||||
description: `Tracked on issue #${issue}`,
|
||||
})
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon: any
|
||||
path?: string
|
||||
comingSoon?: { issue: number; label: string }
|
||||
}
|
||||
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
const bottomTabs: Tab[] = [
|
||||
{ name: 'Posts', icon: Newspaper, path: '/forum' },
|
||||
{ name: 'Spaces', icon: Hash, disabled: true, onClick: () => comingSoon('Spaces', 31) },
|
||||
{ name: 'Spaces', icon: Hash, comingSoon: { issue: 31, label: 'Spaces' } },
|
||||
{ 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) },
|
||||
])
|
||||
{ name: 'Search', icon: Search, comingSoon: { issue: 15, label: 'Search' } },
|
||||
{ name: 'Alerts', icon: Bell, comingSoon: { issue: 32, label: 'Notifications' } },
|
||||
]
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/forum') {
|
||||
return route.path === '/forum' || route.path.startsWith('/submission/')
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -16,24 +16,7 @@ const messages: LocaleMessages = {
|
|||
common: {
|
||||
loading: 'Loading...',
|
||||
error: 'An error occurred',
|
||||
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.'
|
||||
}
|
||||
success: 'Operation successful'
|
||||
},
|
||||
errors: {
|
||||
notFound: 'Page not found',
|
||||
|
|
|
|||
|
|
@ -16,24 +16,7 @@ const messages: LocaleMessages = {
|
|||
common: {
|
||||
loading: 'Cargando...',
|
||||
error: 'Ha ocurrido un error',
|
||||
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.'
|
||||
}
|
||||
success: 'Operación exitosa'
|
||||
},
|
||||
errors: {
|
||||
notFound: 'Página no encontrada',
|
||||
|
|
|
|||
|
|
@ -16,24 +16,7 @@ const messages: LocaleMessages = {
|
|||
common: {
|
||||
loading: 'Chargement...',
|
||||
error: 'Une erreur est survenue',
|
||||
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.'
|
||||
}
|
||||
success: 'Opération réussie'
|
||||
},
|
||||
errors: {
|
||||
notFound: 'Page non trouvée',
|
||||
|
|
|
|||
|
|
@ -16,23 +16,6 @@ export interface LocaleMessages {
|
|||
loading: string
|
||||
error: 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: {
|
||||
notFound: string
|
||||
|
|
|
|||
|
|
@ -1,55 +1,114 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
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 { 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 tabs = computed<BottomTab[]>(() => [
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon: any
|
||||
path?: string
|
||||
authRequired?: boolean
|
||||
badge?: () => number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const bottomTabs = computed<Tab[]>(() => [
|
||||
{ name: 'Browse', icon: Store, path: '/market' },
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ 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' },
|
||||
])
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/market') {
|
||||
return (
|
||||
route.path === '/market' ||
|
||||
route.path.startsWith('/market/stall/') ||
|
||||
route.path.startsWith('/market/product/')
|
||||
)
|
||||
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/')
|
||||
}
|
||||
if (path === '/cart') {
|
||||
return route.path === '/cart' || route.path.startsWith('/checkout/')
|
||||
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
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { RefreshCw, SlidersHorizontal, ChevronDown, Plus, LogIn } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import CreateEventDialog from '../components/CreateEventDialog.vue'
|
||||
|
|
@ -86,6 +86,18 @@ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { User, LogIn, Plus } from 'lucide-vue-next'
|
||||
import { RefreshCw, 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,6 +84,10 @@ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ 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">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@
|
|||
{{ totalUnreadCount }} unread
|
||||
</Badge>
|
||||
</div>
|
||||
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
||||
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4" />
|
||||
<span class="hidden sm:inline ml-2">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Peer List -->
|
||||
|
|
@ -197,6 +202,11 @@
|
|||
{{ totalUnreadCount }} unread
|
||||
</Badge>
|
||||
</div>
|
||||
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
||||
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4" />
|
||||
Refresh Peers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
|
|
@ -359,7 +369,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { Send, MessageSquare, ArrowLeft, Search, X } from 'lucide-vue-next'
|
||||
import { Send, RefreshCw, MessageSquare, ArrowLeft, Search, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -381,6 +391,7 @@ const peers = computed(() => chat.peers.value)
|
|||
const selectedPeer = ref<ChatPeer | null>(null)
|
||||
const messageInput = ref('')
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showChat = ref(false)
|
||||
const messagesScrollArea = ref<HTMLElement | null>(null)
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
|
@ -453,6 +464,10 @@ const goBackToPeers = () => {
|
|||
|
||||
|
||||
|
||||
const refreshPeers = async () => {
|
||||
await chat.refreshPeers()
|
||||
}
|
||||
|
||||
const selectPeer = async (peer: ChatPeer) => {
|
||||
selectedPeer.value = peer
|
||||
messageInput.value = ''
|
||||
|
|
|
|||
|
|
@ -183,8 +183,18 @@ onMounted(() => {
|
|||
<!-- Compact Header -->
|
||||
<div class="flex flex-col gap-3 p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h1 class="text-lg md:text-xl font-bold">Transaction History</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="loadTransactions"
|
||||
: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>
|
||||
|
||||
<!-- Date Range Controls -->
|
||||
|
|
|
|||
|
|
@ -465,6 +465,16 @@ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const copiedField = ref<string | null>(null)
|
|||
|
||||
// Computed
|
||||
const transactions = computed(() => walletService?.transactions?.value || [])
|
||||
const isLoading = computed(() => walletService?.isLoading?.value || false)
|
||||
const error = computed(() => walletService?.error?.value)
|
||||
// Use PaymentService for centralized balance calculation
|
||||
const totalBalance = computed(() => paymentService?.totalBalance || 0)
|
||||
|
|
@ -175,10 +176,15 @@ onMounted(async () => {
|
|||
<div class="container mx-auto py-4 sm:py-8 px-3 sm:px-4 max-w-6xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<Wallet class="h-6 w-6 sm:h-8 sm:w-8" />
|
||||
Wallet
|
||||
</h1>
|
||||
<Button variant="ghost" size="sm" @click="refresh" :disabled="isLoading">
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': isLoading }" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-sm sm:text-base text-muted-foreground mt-1">Manage your Bitcoin transactions</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } 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 ProfileSheetTrigger from '@/components/layout/ProfileSheetTrigger.vue'
|
||||
import PreferencesRow from '@/components/layout/PreferencesRow.vue'
|
||||
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'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { theme, setTheme, currentTheme } = useTheme()
|
||||
const { currentLocale, locales, setLocale } = useLocale()
|
||||
|
||||
interface Module {
|
||||
label: string
|
||||
|
|
@ -73,6 +85,14 @@ 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>
|
||||
|
|
@ -134,17 +154,85 @@ function onTileClick(m: Module, event: Event) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- Bottom bar: profile & user preferences -->
|
||||
<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">
|
||||
<ProfileSheetTrigger :logged-out-opens-sheet="true" />
|
||||
<PreferencesRow layout="row" />
|
||||
<!-- 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>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
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'
|
||||
|
||||
// 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[] = []
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
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'
|
||||
|
||||
// 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[] = []
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue