open-navigator / web_app /src /components /SiteHeader.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
13.7 kB
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { DATABRICKS_APP_URL, DATABRICKS_WORKSPACE_URL } from '../utils/adminPaths'
const TEAL = '#0d9488'
const TEAL_DARK = '#0f766e'
const INK = '#1c1917'
/** Scoped styles for the shared site header. Kept self-contained (scoped under
* `.site-header`) so the header renders identically whether it sits inside the
* home page's `.v9` wrapper or the global Layout. */
const HEADER_CSS = `
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;800;900&family=Source+Sans+3:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
.site-header { font-family: 'Source Sans 3', system-ui, sans-serif; }
.site-header .font-display { font-family: 'Playfair Display', Georgia, serif; }
.site-header .font-mono-x { font-family: 'IBM Plex Mono', monospace; }
.site-header .v9-burger { display: none; }
.site-header .v9-navlink { position: relative; background: none; border: none; padding: 4px 0; font-size: 14.5px; font-weight: 600; color: #44403c; cursor: pointer; font-family: inherit; transition: color .2s ease; }
.site-header .v9-navlink::after { content: ''; position: absolute; left: 0; bottom: -3px; height: 2px; width: 0; background: ${TEAL}; transition: width .25s ease; }
.site-header .v9-navlink:hover { color: ${TEAL_DARK}; }
.site-header .v9-navlink:hover::after { width: 100%; }
.site-header .v9-navlink.active { color: ${TEAL_DARK}; }
.site-header .v9-navlink.active::after { width: 100%; }
.site-header .v9-nav { display: flex; gap: 22px; margin-left: auto; align-items: center; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.site-header .v9-nav { display: none; }
.site-header .v9-nav.open { display: flex; flex-direction: column; align-items: stretch; position: absolute; top: 100%; left: 0; right: 0; background: #fff; border-bottom: 1px solid #e7e5e4; padding: 10px 16px 16px; gap: 4px; box-shadow: 0 16px 32px rgba(28,25,23,0.1); }
.site-header .v9-burger { display: grid; margin-left: auto; }
.site-header .v9-brand-sub { display: none; }
.site-header .v9-header-inner { padding-left: 16px !important; padding-right: 16px !important; gap: 12px !important; }
}
`
/**
* The single, shared top navigation header used by both the home page and the
* global Layout (search page and every other route). Edit this one place to
* change the header everywhere.
*/
export default function SiteHeader() {
const navigate = useNavigate()
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
const [showLoginMenu, setShowLoginMenu] = useState(false)
// Which in-page section is currently active, so its nav link stays highlighted
// after a click and as the user scrolls through it.
const [activeSection, setActiveSection] = useState('')
const { user, isAuthenticated, login, logout, isLoading: authLoading } = useAuth()
// Scroll-spy: highlight the nav link for whichever section sits in the middle
// of the viewport. Only the home page hosts these sections, so clear the
// active section on every other route.
useEffect(() => {
if (location.pathname !== '/') {
setActiveSection('')
return
}
const ids = ['how-it-works', 'impact']
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) setActiveSection(e.target.id)
})
},
{ rootMargin: '-50% 0px -50% 0px' },
)
// Sections may mount a frame after the header; defer the lookup so we attach.
const raf = requestAnimationFrame(() => {
ids.forEach((id) => {
const el = document.getElementById(id)
if (el) observer.observe(el)
})
})
return () => {
cancelAnimationFrame(raf)
observer.disconnect()
}
}, [location.pathname])
// "How It Works" / "Impact" are in-page sections that only exist on the home
// page. On home we smooth-scroll to them; elsewhere we route home with a hash
// so HomeV9's hash-scroll effect lands on the right section.
const goSection = (id: string) => {
setActiveSection(id) // keep the clicked link highlighted immediately
if (location.pathname === '/') {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
navigate(`/#${id}`)
}
}
const navItems: { label: string; onClick: () => void; active: boolean }[] = [
{ label: 'Search', onClick: () => navigate('/search'), active: location.pathname === '/search' },
{ label: 'How It Works', onClick: () => goSection('how-it-works'), active: location.pathname === '/' && activeSection === 'how-it-works' },
{ label: 'Impact', onClick: () => goSection('impact'), active: location.pathname === '/' && activeSection === 'impact' },
{ label: 'Contact', onClick: () => navigate('/support'), active: location.pathname === '/support' },
]
return (
<header className="site-header" style={{ position: 'sticky', top: 0, zIndex: 50, background: '#fff', borderBottom: '1px solid #e7e5e4' }}>
<style>{HEADER_CSS}</style>
<div className="v9-header-inner" style={{ maxWidth: 1180, margin: '0 auto', padding: '12px 24px', display: 'flex', alignItems: 'center', gap: 24, position: 'relative' }}>
<Link
to="/"
onClick={() => {
// The logo always returns home and starts at the top. When already
// on '/', the pathname doesn't change so the global ScrollToTop
// effect won't fire — reset scroll here to cover that case too.
setMenuOpen(false)
window.scrollTo(0, 0)
}}
style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0, textDecoration: 'none', color: INK }}
>
<div
className="font-mono-x"
style={{ width: 38, height: 38, borderRadius: '50%', border: `2.5px solid ${TEAL}`, display: 'grid', placeItems: 'center', fontWeight: 800, color: TEAL, fontSize: 15 }}
>
C1
</div>
<div>
<div className="font-display" style={{ fontSize: 19, fontWeight: 700, lineHeight: 1.1 }}>
Open Navigator
</div>
<div className="v9-brand-sub" style={{ fontSize: 11.5, color: '#78716c' }}>
by CommunityOne
</div>
</div>
</Link>
<button
className="v9-burger"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setMenuOpen(!menuOpen)}
style={{ width: 40, height: 40, border: '1px solid #e7e5e4', borderRadius: 10, background: '#fff', cursor: 'pointer', placeItems: 'center', fontSize: 18, color: INK }}
>
{menuOpen ? '✕' : '☰'}
</button>
<nav className={'v9-nav' + (menuOpen ? ' open' : '')}>
{navItems.map(({ label, onClick, active }) => (
<button
key={label}
className={'v9-navlink' + (active ? ' active' : '')}
onClick={() => {
setMenuOpen(false)
onClick()
}}
>
{label}
</button>
))}
{/* Register / Login — auth-aware */}
{authLoading ? (
<div style={{ width: 34, height: 34, borderRadius: '50%', border: `3px solid #e7e5e4`, borderTopColor: TEAL, animation: 'spin 0.8s linear infinite' }} />
) : isAuthenticated && user ? (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowLoginMenu(!showLoginMenu)}
style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', padding: '4px 6px' }}
>
{user.avatar_url ? (
<img src={user.avatar_url} alt={user.full_name || user.email} referrerPolicy="no-referrer" style={{ width: 34, height: 34, borderRadius: '50%', border: `2px solid ${TEAL}`, objectFit: 'cover' }} />
) : (
<span style={{ width: 34, height: 34, borderRadius: '50%', background: TEAL, color: '#fff', display: 'grid', placeItems: 'center', fontWeight: 700, fontSize: 14 }}>
{(user.full_name || user.username || user.email).charAt(0).toUpperCase()}
</span>
)}
<span style={{ fontSize: 14, fontWeight: 600, color: '#44403c' }}>
{user.full_name || user.username || user.email.split('@')[0]}
</span>
</button>
{showLoginMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', marginTop: 8, width: 200, background: '#fff', border: '1px solid #e7e5e4', borderRadius: 12, boxShadow: '0 16px 32px rgba(28,25,23,0.12)', padding: 6, zIndex: 60 }}>
<button onClick={() => { setShowLoginMenu(false); navigate('/data-explorer/map/us/2024/median_household_income') }} style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 12px', fontSize: 14, fontWeight: 600, color: TEAL_DARK, cursor: 'pointer', fontFamily: 'inherit', borderBottom: '1px solid #f5f5f4', marginBottom: 4, borderRadius: 8 }}>Explore Now</button>
<button onClick={() => { setShowLoginMenu(false); navigate('/profile') }} style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 12px', fontSize: 14, color: '#44403c', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 8 }}>My Profile</button>
<button onClick={() => { setShowLoginMenu(false); navigate('/settings') }} style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 12px', fontSize: 14, color: '#44403c', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 8 }}>Settings</button>
{user.is_admin && (
<button onClick={() => { setShowLoginMenu(false); navigate('/admin') }} style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 12px', fontSize: 14, color: '#44403c', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 8 }}>Admin</button>
)}
{user.is_admin && (
<>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 0.4, textTransform: 'uppercase', color: '#a8a29e', padding: '8px 12px 2px', borderTop: '1px solid #f5f5f4', marginTop: 4 }}>Databricks</div>
<a href={DATABRICKS_WORKSPACE_URL} target="_blank" rel="noopener noreferrer" onClick={() => setShowLoginMenu(false)} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 12px', fontSize: 14, color: '#44403c', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 8, textDecoration: 'none' }}>Workspace ↗</a>
<a href={DATABRICKS_APP_URL} target="_blank" rel="noopener noreferrer" onClick={() => setShowLoginMenu(false)} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 12px', fontSize: 14, color: '#44403c', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 8, textDecoration: 'none' }}>App ↗</a>
</>
)}
<button onClick={() => { setShowLoginMenu(false); logout() }} style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 12px', fontSize: 14, color: '#dc2626', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', borderTop: '1px solid #f5f5f4', marginTop: 4, borderRadius: 8 }}>Sign out</button>
</div>
)}
</div>
) : (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowLoginMenu(!showLoginMenu)}
style={{ display: 'flex', alignItems: 'center', gap: 8, background: INK, color: '#fff', border: 'none', borderRadius: 10, padding: '10px 18px', fontSize: 14.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}
>
<span>Register/Login</span>
<span style={{ fontSize: 11 }}></span>
</button>
{showLoginMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', marginTop: 8, width: 220, background: '#fff', border: '1px solid #e7e5e4', borderRadius: 12, boxShadow: '0 16px 32px rgba(28,25,23,0.12)', padding: 8, zIndex: 60 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#78716c', padding: '4px 8px 8px' }}>Sign in with:</div>
{(['google', 'facebook', 'github', 'huggingface'] as const).map((provider) => (
<button
key={provider}
onClick={() => { setShowLoginMenu(false); login(provider) }}
style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 12px', fontSize: 14, fontWeight: 600, color: '#44403c', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 8, textTransform: 'capitalize' }}
>
{provider === 'huggingface' ? 'HuggingFace' : provider}
</button>
))}
</div>
)}
</div>
)}
</nav>
</div>
</header>
)
}