mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-20 13:28:21 +02:00
253 lines
8.9 KiB
TypeScript
253 lines
8.9 KiB
TypeScript
import { useState, type PropsWithChildren } from 'react'
|
|
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
|
import type { TFunction } from 'i18next'
|
|
import { LockKeyholeIcon, LogOutIcon, PackageSearchIcon, SettingsIcon, ShuffleIcon, WalletIcon } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Link, useNavigate, type NavigateFunction } from 'react-router-dom'
|
|
import { DevBadge } from '@/components/dev/DevBadge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ThemeToggleButton } from '@/components/ui/jam/ThemeToggleButton'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import type { SidebarContextProps } from '@/components/ui/use-sidebar'
|
|
import { isDevMode } from '@/constants/debugFeatures'
|
|
import { routes } from '@/constants/routes'
|
|
import type { RescanInfo } from '@/context/JamSessionInfoContext'
|
|
import { cn, shortenStringMiddle } from '@/lib/utils'
|
|
import type { AmountSats } from '@/types/global'
|
|
import { Balance } from '../ui/jam/Balance'
|
|
import { Spinner } from '../ui/spinner'
|
|
|
|
const WithActivityIndicator = ({ active, children }: PropsWithChildren<{ active: boolean }>) => {
|
|
return (
|
|
<span className="relative">
|
|
{children}
|
|
<span
|
|
className={cn('absolute -top-1 -right-2 text-[8px]', {
|
|
'light:text-green-600 text-green-300 motion-safe:animate-pulse': active,
|
|
})}
|
|
>
|
|
●
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
type WalletPreviewProps = {
|
|
isLoading?: boolean
|
|
isReloading?: boolean
|
|
walletName: string | null
|
|
totalBalance: AmountSats
|
|
rescanInfo?: RescanInfo
|
|
}
|
|
|
|
const WalletPreview = ({
|
|
walletName,
|
|
totalBalance,
|
|
isLoading = false,
|
|
isReloading = false,
|
|
rescanInfo,
|
|
}: WalletPreviewProps) => {
|
|
const { t } = useTranslation()
|
|
const walletNameTitle = shortenStringMiddle(walletName ?? '...', 12)
|
|
|
|
return (
|
|
<div className="flex flex-1 items-center">
|
|
<Link to={routes.home} className="flex items-center gap-2" data-tour-id="wallet-preview">
|
|
<div className="flex h-8 w-8 items-center">
|
|
{isLoading || isReloading ? (
|
|
<Spinner className="text-muted-foreground size-6 motion-reduce:hidden" strokeWidth={3} />
|
|
) : (
|
|
<WalletIcon strokeWidth={1} />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-0.25 leading-none">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="font-semibold tracking-tight"
|
|
title={walletName && walletName.length !== walletNameTitle.length ? walletName : undefined}
|
|
>
|
|
{walletNameTitle}
|
|
</div>
|
|
{isDevMode() && <DevBadge />}
|
|
</div>
|
|
<div className="flex min-h-5 min-w-[150px] items-center">
|
|
{rescanInfo?.rescanning === true ? (
|
|
<div className="cursor-wait motion-safe:animate-pulse">
|
|
{rescanInfo.progress !== undefined
|
|
? t('navbar.text_rescan_in_progress_with_progress', {
|
|
progress: Math.floor(rescanInfo.progress * 100),
|
|
})
|
|
: t('navbar.text_rescan_in_progress')}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{isLoading ? (
|
|
<Skeleton className="h-4 w-full bg-neutral-200 dark:bg-neutral-600" />
|
|
) : (
|
|
<Balance valueString={String(totalBalance)} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type SessionInfo = Pick<SessionResponse, 'maker_running' | 'coinjoin_in_process' | 'schedule'>
|
|
type SidebarInfo = Pick<SidebarContextProps, 'isMobile' | 'open' | 'openMobile'>
|
|
|
|
type AppNavbarProps = WalletPreviewProps & {
|
|
theme: string
|
|
toggleTheme: () => void
|
|
onLogout: (navigate: NavigateFunction) => Promise<void>
|
|
onLockWallet: (navigate: NavigateFunction, t: TFunction<'translation', undefined>) => Promise<void>
|
|
sidebarTrigger?: React.ReactNode
|
|
sessionInfo?: SessionInfo
|
|
sidebarInfo?: SidebarInfo
|
|
rescanInfo?: RescanInfo
|
|
}
|
|
|
|
export function AppNavbar({
|
|
isLoading = false,
|
|
isReloading = false,
|
|
walletName,
|
|
totalBalance,
|
|
theme,
|
|
toggleTheme,
|
|
onLogout,
|
|
onLockWallet,
|
|
sidebarTrigger,
|
|
sessionInfo,
|
|
sidebarInfo,
|
|
rescanInfo,
|
|
}: AppNavbarProps) {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
|
|
const isSidebarOpen =
|
|
sidebarInfo === undefined ? false : sidebarInfo.isMobile ? sidebarInfo.openMobile : sidebarInfo.open
|
|
|
|
const makerRunning = sessionInfo?.maker_running === true
|
|
const singleCoinJoinRunning = sessionInfo?.coinjoin_in_process === true && !sessionInfo?.schedule
|
|
const schedulerRunning = sessionInfo?.coinjoin_in_process === true && !!sessionInfo?.schedule
|
|
|
|
const joiningRoute = (() => {
|
|
if (schedulerRunning) return routes.sweep
|
|
if (singleCoinJoinRunning) return routes.send
|
|
if (makerRunning) return routes.earn
|
|
|
|
return undefined
|
|
})()
|
|
|
|
const rescanningRoute = rescanInfo?.rescanning !== true ? undefined : routes.rescan
|
|
|
|
const [isLockingWallet, setIsLockingWallet] = useState(false)
|
|
const doOnLockWallet = async () => {
|
|
try {
|
|
setIsLockingWallet(true)
|
|
await onLockWallet(navigate, t)
|
|
} finally {
|
|
setIsLockingWallet(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<header className="light:bg-gray-100 light:text-black flex items-center justify-between bg-[#23262b] px-4 py-2 text-white transition-colors duration-300">
|
|
<WalletPreview
|
|
isLoading={isLoading}
|
|
isReloading={isReloading}
|
|
walletName={walletName}
|
|
totalBalance={totalBalance}
|
|
rescanInfo={rescanInfo}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'hidden min-w-0 flex-1 items-center justify-center text-sm font-semibold md:gap-6 lg:gap-12 lg:text-base',
|
|
{
|
|
'md:flex': !isSidebarOpen,
|
|
'lg:flex': isSidebarOpen,
|
|
'blur-[1px]': rescanInfo?.rescanning === true,
|
|
},
|
|
)}
|
|
>
|
|
<Link to={rescanningRoute ? '#' : routes.receive} className="text-muted-foreground hover:text-foreground">
|
|
{t('navbar.tab_receive')}
|
|
</Link>
|
|
<Link to={rescanningRoute ? '#' : routes.send} className="text-muted-foreground hover:text-foreground relative">
|
|
<WithActivityIndicator active={singleCoinJoinRunning}>{t('navbar.tab_send')}</WithActivityIndicator>
|
|
</Link>
|
|
<Link to={rescanningRoute ? '#' : routes.earn} className="text-muted-foreground hover:text-foreground relative">
|
|
<WithActivityIndicator active={makerRunning}>{t('navbar.tab_earn')}</WithActivityIndicator>
|
|
</Link>
|
|
<span className="text-gray-400 dark:text-gray-600">|</span>
|
|
<Link
|
|
to={rescanningRoute ? '#' : routes.sweep}
|
|
className="text-muted-foreground hover:text-foreground relative"
|
|
>
|
|
<WithActivityIndicator active={schedulerRunning}>{t('navbar.tab_sweep')}</WithActivityIndicator>
|
|
</Link>
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 sm:gap-2">
|
|
{rescanningRoute && (
|
|
<Button
|
|
className="light:text-green-600 text-green-300"
|
|
variant="ghost-navbar"
|
|
size="icon"
|
|
onClick={() => void navigate(rescanningRoute)}
|
|
aria-label={t('navbar.text_rescan_in_progress')}
|
|
title={t('navbar.text_rescan_in_progress')}
|
|
>
|
|
<PackageSearchIcon className="motion-safe:animate-pulse" />
|
|
</Button>
|
|
)}
|
|
{joiningRoute && (
|
|
<Button
|
|
className="light:text-green-600 text-green-300"
|
|
variant="ghost-navbar"
|
|
size="icon"
|
|
onClick={() => void navigate(joiningRoute)}
|
|
aria-label={t('navbar.joining_in_progress')}
|
|
title={t('navbar.joining_in_progress')}
|
|
>
|
|
<ShuffleIcon className="motion-safe:animate-pulse" />
|
|
</Button>
|
|
)}
|
|
<ThemeToggleButton className="hidden sm:flex" variant="ghost-navbar" theme={theme} onClick={toggleTheme} />
|
|
<Button
|
|
data-tour-id="settings-button"
|
|
variant="ghost-navbar"
|
|
size="icon"
|
|
onClick={() => void navigate(routes.settings)}
|
|
aria-label={t('navbar.menu_mobile_settings')}
|
|
title={t('navbar.menu_mobile_settings')}
|
|
>
|
|
<SettingsIcon />
|
|
</Button>
|
|
<Button
|
|
className="hidden sm:flex"
|
|
variant="ghost-navbar"
|
|
size="icon"
|
|
onClick={() => void doOnLockWallet()}
|
|
aria-label={t('settings.button_lock_wallet')}
|
|
title={t('settings.button_lock_wallet')}
|
|
disabled={isLockingWallet}
|
|
>
|
|
<LockKeyholeIcon />
|
|
</Button>
|
|
<Button
|
|
className="hidden sm:flex"
|
|
variant="ghost-navbar"
|
|
size="icon"
|
|
onClick={() => void onLogout(navigate)}
|
|
aria-label={/* TODO: i18n */ 'Logout'}
|
|
title={/* TODO: i18n */ 'Logout'}
|
|
>
|
|
<LogOutIcon />
|
|
</Button>
|
|
{sidebarTrigger}
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|