refactor(desktop-header): elegant labeled nav, drop hover-reveal + dead sidebar

The desktop top-bar nav rendered icon-only buttons whose label only slid out
on hover (max-w-0 -> group-hover:max-w-40) — mystery-meat, jumpy, hard to
scan. Replace with always-visible 'icon + label' links and a single
framer-motion pill (shared layoutId) that smoothly slides to the active item;
compact spacing, refined active/hover states.

Also delete DesktopSidebar.tsx — dead code since the cabinet's inception
(never imported anywhere, only re-exported from the barrel).
This commit is contained in:
c0mrade
2026-06-05 00:16:01 +03:00
parent 800daf7506
commit e4cd975d9c
3 changed files with 51 additions and 288 deletions

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router'; import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { useHaptic } from '@/platform'; import { useHaptic } from '@/platform';
@@ -109,11 +110,13 @@ export function AppShell({ children }: AppShellProps) {
}; };
}, []); }, []);
// Desktop navigation items // Desktop navigation — labels always visible (no hover-reveal gimmick)
const desktopNavItems = [ const desktopNav = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon }, { path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon }, { path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon }, { path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon }, { path: '/support', label: t('nav.support'), icon: ChatIcon },
{ path: '/info', label: t('nav.info'), icon: InfoIcon }, { path: '/info', label: t('nav.info'), icon: InfoIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }, { path: '/profile', label: t('nav.profile'), icon: UserIcon },
@@ -128,6 +131,48 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light'); haptic.impact('light');
}; };
// A single elegant nav link: icon + label always visible, with a shared
// framer-motion pill that slides to the active item on navigation.
const renderNavLink = (
path: string,
label: string,
Icon: React.ComponentType<{ className?: string }>,
admin = false,
) => {
const active = admin ? location.pathname.startsWith('/admin') : isActive(path);
return (
<Link
key={path}
to={path}
onClick={handleNavClick}
aria-label={label}
className={cn(
'relative flex items-center gap-2 rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
active
? admin
? 'text-warning-400'
: 'text-dark-50'
: admin
? 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100',
)}
>
{active && (
<motion.span
layoutId="desktop-nav-active"
className={cn(
'absolute inset-0 rounded-lg',
admin ? 'bg-warning-500/10' : 'bg-dark-800',
)}
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
<Icon className="relative h-[17px] w-[17px] shrink-0" />
<span className="relative whitespace-nowrap">{label}</span>
</Link>
);
};
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen // headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
return ( return (
@@ -170,81 +215,12 @@ export function AppShell({ children }: AppShellProps) {
</Link> </Link>
{/* Center Navigation */} {/* Center Navigation */}
<nav className="flex min-w-0 items-center gap-1"> <nav className="flex min-w-0 items-center justify-center gap-0.5">
{desktopNavItems.map((item) => ( {desktopNav.map((item) => renderNavLink(item.path, item.label, item.icon))}
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
aria-label={item.label}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{item.label}
</span>
</Link>
))}
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
aria-label={t('nav.referral')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.referral')}
</span>
</Link>
)}
{giftEnabled && (
<Link
to="/gift"
onClick={handleNavClick}
aria-label={t('nav.gift')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/gift')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<GiftIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.gift')}
</span>
</Link>
)}
{isAdmin && ( {isAdmin && (
<> <>
<div className="mx-1 h-5 w-px shrink-0 bg-dark-700" /> <div className="mx-1.5 h-5 w-px shrink-0 bg-dark-700/70" />
<Link {renderNavLink('/admin', t('admin.nav.title'), ShieldIcon, true)}
to="/admin"
onClick={handleNavClick}
aria-label={t('admin.nav.title')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<ShieldIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('admin.nav.title')}
</span>
</Link>
</> </>
)} )}
</nav> </nav>

View File

@@ -1,212 +0,0 @@
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { displayName } from '@/utils/displayName';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
// Icons
import {
HomeIcon,
SubscriptionIcon,
WalletIcon,
UsersIcon,
ChatIcon,
UserIcon,
LogoutIcon,
GamepadIcon,
ClipboardIcon,
InfoIcon,
CogIcon,
WheelIcon,
} from './icons';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface DesktopSidebarProps {
isAdmin?: boolean;
wheelEnabled?: boolean;
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
}
export function DesktopSidebar({
isAdmin,
wheelEnabled,
referralEnabled,
hasContests,
hasPolls,
}: DesktopSidebarProps) {
const { t } = useTranslation();
const location = useLocation();
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const { haptic } = usePlatform();
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
await preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 60000,
refetchOnWindowFocus: true,
retry: 1,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
const isAdminActive = () => location.pathname.startsWith('/admin');
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
];
const handleNavClick = () => {
haptic.impact('light');
};
return (
<aside className="h-viewport fixed left-0 top-0 z-40 flex w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-dark-700/30 px-4">
<Link to="/" className="flex items-center gap-3" onClick={handleNavClick}>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-linear-lg border border-dark-700/50 bg-dark-800/80">
<span
className={cn(
'absolute text-lg font-bold text-accent-400 transition-opacity duration-200',
hasCustomLogo && isLogoPreloaded() ? 'opacity-0' : 'opacity-100',
)}
>
{logoLetter}
</span>
{hasCustomLogo && logoUrl && (
<img
src={logoUrl}
alt={appName || 'Logo'}
className={cn(
'absolute h-full w-full object-contain transition-opacity duration-200',
isLogoPreloaded() ? 'opacity-100' : 'opacity-0',
)}
/>
)}
</div>
{appName && (
<span className="whitespace-nowrap text-base font-semibold text-dark-100">
{appName}
</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 text-sm font-medium transition-all duration-200',
isActive(item.path)
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100',
)}
>
<item.icon className="h-5 w-5 shrink-0" />
<span>{item.label}</span>
{isActive(item.path) && (
<motion.div
layoutId="sidebar-active-indicator"
className="absolute left-0 h-8 w-0.5 rounded-r-full bg-accent-400"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
</Link>
))}
{/* Admin section */}
{isAdmin && (
<>
<div className="my-3 h-px bg-dark-700/30" />
<Link
to="/admin"
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 text-sm font-medium transition-all duration-200',
isAdminActive()
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<CogIcon className="h-5 w-5 shrink-0" />
<span>{t('admin.nav.title')}</span>
</Link>
</>
)}
</nav>
{/* User section */}
<div className="border-t border-dark-700/30 p-3">
<Link
to="/profile"
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 transition-all duration-200',
isActive('/profile') ? 'bg-dark-800/80' : 'hover:bg-dark-800/50',
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
<UserIcon className="h-4 w-4 text-dark-400" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{displayName(user)}</p>
<p className="truncate text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</p>
</div>
</Link>
<button
onClick={() => {
haptic.impact('light');
logout();
}}
className="mt-2 flex w-full items-center gap-3 rounded-linear px-3 py-2.5 text-sm text-dark-400 transition-all duration-200 hover:bg-error-500/10 hover:text-error-400"
>
<LogoutIcon className="h-5 w-5 shrink-0" />
<span>{t('nav.logout')}</span>
</button>
</div>
</aside>
);
}

View File

@@ -1,4 +1,3 @@
export { AppShell } from './AppShell'; export { AppShell } from './AppShell';
export { DesktopSidebar } from './DesktopSidebar';
export { MobileBottomNav } from './MobileBottomNav'; export { MobileBottomNav } from './MobileBottomNav';
export { AppHeader } from './AppHeader'; export { AppHeader } from './AppHeader';