Files
ChatApp/apps/desktop/src/components/Sidebar.tsx
T
byGalax 36ab7eca8a perf(P6A.T4): wrap all icon components in React.memo
All 57 exported SVG icon components in icons.tsx are now memoised via
React.memo, giving React permission to skip re-renders when props are
referentially equal.  Consumer icon-prop types updated from the legacy
SVGProps (includes string refs) to ComponentPropsWithoutRef<'svg'> so
the MemoExoticComponent return type satisfies TypeScript without casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:39:47 +02:00

174 lines
5.4 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import {
ChatBubbleIcon,
GearIcon,
LogoMark,
ShieldIcon,
SignOutIcon,
SparklesIcon,
UsersIcon,
} from './icons';
interface NavItem {
to: string;
labelKey: string;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: 'friends' | 'chats';
}
const PRIMARY_NAV: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
];
const ADMIN_NAV: NavItem = {
to: '/admin',
labelKey: 'app:nav.admin',
icon: ShieldIcon,
};
// Clean-Rail 72px icon rail. Brand logo top → primary tabs → spacer →
// settings + theme toggle + sign-out at bottom. Active tab shows a pill
// indicator anchored to the left edge of the rail.
export function Sidebar() {
const { t } = useTranslation(['app']);
const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext();
const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
return (
<aside
aria-label="Primary navigation"
className="discord-rail flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2 py-3"
>
<div
className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent text-accent-fg shadow-[0_10px_26px_rgba(88,101,242,0.32)]"
title="Netralax"
>
<LogoMark className="h-10 w-10" />
</div>
<div className="my-3 h-0.5 w-8 rounded-full bg-line" aria-hidden="true" />
<nav className="flex flex-col items-center gap-2.5">
{navItems.map((item) => {
const badgeCount =
item.badge === 'friends' ? incomingCount : item.badge === 'chats' ? totalUnread : 0;
return (
<RailNavLink
key={item.to}
to={item.to}
label={t(item.labelKey)}
icon={item.icon}
badge={badgeCount}
/>
);
})}
</nav>
<div className="flex-1" />
<div className="flex flex-col items-center gap-2.5">
<RailNavLink
to="/changelog"
label={t('app:nav.changelog', { defaultValue: 'Was ist neu' })}
icon={SparklesIcon}
/>
<RailNavLink to="/settings" label={t('app:nav.settings')} icon={GearIcon} />
<RailIconButton
label={t('app:sidebar.sign_out')}
onClick={() => void signOut()}
icon={SignOutIcon}
tone="danger"
/>
</div>
</aside>
);
}
interface RailNavLinkProps {
to: string;
label: string;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: number;
}
function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
const ariaLabel = badge > 0 ? `${label} (${badge})` : label;
return (
<NavLink
to={to}
aria-label={ariaLabel}
title={label}
className={({ isActive }) =>
[
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
isActive
? 'bg-accent text-accent-fg shadow-[0_8px_22px_rgba(88,101,242,0.34)]'
: 'text-fg-muted hover:rounded-2xl hover:bg-surface-3/80 hover:text-fg dark:hover:bg-[#313338]',
].join(' ')
}
>
{({ isActive }) => (
<>
{isActive && (
<span
aria-hidden="true"
className="absolute -left-[15px] h-7 w-1 rounded-r-full bg-fg"
/>
)}
<Icon style={{ width: '20px', height: '20px' }} />
{badge > 0 && <RailBadge count={badge} />}
</>
)}
</NavLink>
);
}
interface RailIconButtonProps {
label: string;
onClick: () => void;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
tone?: 'default' | 'danger';
}
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
const toneClass =
tone === 'danger'
? 'text-fg-muted hover:rounded-2xl hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
: 'text-fg-muted hover:rounded-2xl hover:bg-surface-3/80 hover:text-fg focus-visible:ring-accent/50 dark:hover:bg-[#313338]';
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition-all duration-150 focus:outline-none focus-visible:ring-2 ' +
toneClass
}
>
<Icon style={{ width: '20px', height: '20px' }} />
</button>
);
}
function RailBadge({ count }: { count: number }) {
const display = count > 99 ? '99+' : String(count);
return (
<span
aria-hidden="true"
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2 dark:ring-[#1e1f22]"
>
{display}
</span>
);
}