36ab7eca8a
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>
174 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|