← back to Grant
components/Sidebar.tsx
323 lines
'use client';
import { useState } from 'react';
import {
LayoutDashboard,
Award,
Newspaper,
Users,
Megaphone,
Settings,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export type TabId =
| 'dashboard'
| 'grants'
| 'news'
| 'collaborations'
| 'outreach'
| 'settings';
interface NavItem {
id: TabId;
label: string;
Icon: React.ElementType;
section: 'main' | 'tools';
}
interface SidebarProps {
activeTab: TabId;
onTabChange: (tab: TabId) => void;
isOpen?: boolean;
onToggle?: () => void;
}
/* ─── Navigation config ──────────────────────────────────────────────────── */
const NAV_ITEMS: NavItem[] = [
{ id: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, section: 'main' },
{ id: 'grants', label: 'Grants', Icon: Award, section: 'main' },
{ id: 'news', label: 'News', Icon: Newspaper, section: 'main' },
{ id: 'collaborations', label: 'Collaborations', Icon: Users, section: 'main' },
{ id: 'outreach', label: 'Outreach', Icon: Megaphone, section: 'tools' },
{ id: 'settings', label: 'Settings', Icon: Settings, section: 'tools' },
];
/* ─── Sidebar ────────────────────────────────────────────────────────────── */
export default function Sidebar({ activeTab, onTabChange, isOpen, onToggle }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const mainItems = NAV_ITEMS.filter((i) => i.section === 'main');
const toolItems = NAV_ITEMS.filter((i) => i.section === 'tools');
return (
<>
{isOpen && onToggle && (
<div
onClick={onToggle}
className="sidebar-backdrop"
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 39,
}}
/>
)}
<aside
className={`sidebar-root${isOpen ? ' sidebar-open' : ''}`}
style={{
width: collapsed ? 64 : 220,
minWidth: collapsed ? 64 : 220,
transition: 'width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
height: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
position: 'relative',
overflow: 'hidden',
flexShrink: 0,
}}
aria-label="Main navigation"
>
{/* ── Brand ──────────────────────────────────────────────────────────── */}
<div
style={{
padding: collapsed ? '16px 0' : '16px',
display: 'flex',
alignItems: 'center',
gap: 10,
justifyContent: collapsed ? 'center' : 'flex-start',
borderBottom: '1px solid var(--color-border)',
height: 56,
flexShrink: 0,
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 10,
background: 'linear-gradient(135deg, #059669, #047857)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 2px 8px rgba(5, 150, 105, 0.3)',
}}
>
<span style={{ color: '#fff', fontSize: 14, fontWeight: 900, lineHeight: 1 }}>G</span>
</div>
{!collapsed && (
<div style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>
Grant
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2, letterSpacing: '0.02em' }}>
Non-Profit Fundraiser
</div>
</div>
)}
</div>
{/* ── Main Nav ───────────────────────────────────────────────────────── */}
<nav style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 8px 0' }}>
{!collapsed && (
<div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Workspace
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mainItems.map(({ id, label, Icon }) => (
<SidebarButton
key={id}
id={id}
label={label}
Icon={Icon}
isActive={activeTab === id}
collapsed={collapsed}
onClick={() => onTabChange(id)}
/>
))}
</div>
{/* Divider */}
<div
style={{
height: 1,
backgroundColor: 'var(--color-border)',
margin: collapsed ? '12px 8px' : '12px 4px',
opacity: 0.6,
}}
/>
{!collapsed && (
<div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Tools
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{toolItems.map(({ id, label, Icon }) => (
<SidebarButton
key={id}
id={id}
label={label}
Icon={Icon}
isActive={activeTab === id}
collapsed={collapsed}
onClick={() => onTabChange(id)}
/>
))}
</div>
{/* 2026-05-05 (tick 38): /river demo link — public visualization of */}
{/* federal grant data as fish swimming toward close-date. */}
{!collapsed && (
<a
href="/river?q=education"
target="_blank"
rel="noopener"
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', marginTop: 8,
border: '1px dashed var(--color-border)', borderRadius: 6,
color: 'var(--color-accent, #10b981)', textDecoration: 'none',
fontSize: 12, fontWeight: 500,
}}
title="Deadline River — federal grants visualized"
>
<span style={{ fontSize: 14 }}>~~</span> Deadline River <span style={{ marginLeft: 'auto', fontSize: 9, opacity: 0.6 }}>↗</span>
</a>
)}
</nav>
{/* ── Collapse toggle ────────────────────────────────────────────────── */}
<div
style={{
padding: '8px',
borderTop: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
<button
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 8,
border: 'none',
backgroundColor: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 12,
transition: 'background-color 0.15s, color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--color-text-muted)';
}}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
{!collapsed && <span>Collapse</span>}
</button>
</div>
</aside>
</>
);
}
/* ─── SidebarButton ──────────────────────────────────────────────────────── */
function SidebarButton({
id,
label,
Icon,
isActive,
collapsed,
onClick,
}: {
id: string;
label: string;
Icon: React.ElementType;
isActive: boolean;
collapsed: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
title={collapsed ? label : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '10px 0' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius: 8,
border: 'none',
cursor: 'pointer',
position: 'relative',
backgroundColor: isActive ? 'rgba(5, 150, 105, 0.12)' : 'transparent',
color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
transition: 'background-color 0.15s, color 0.15s',
width: '100%',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
e.currentTarget.style.color = 'var(--color-text)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}
}}
>
{/* Active indicator bar */}
{isActive && (
<div
style={{
position: 'absolute',
left: 0,
top: '20%',
bottom: '20%',
width: 3,
borderRadius: '0 3px 3px 0',
background: 'linear-gradient(180deg, #34d399, #059669)',
}}
/>
)}
<Icon
size={18}
aria-hidden="true"
style={{
color: isActive ? '#34d399' : 'currentColor',
flexShrink: 0,
transition: 'color 0.15s',
}}
/>
{!collapsed && <span>{label}</span>}
{collapsed && <span className="sr-only">{label}</span>}
</button>
);
}