← back to Norma
components/AppShell.tsx
816 lines
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { LogOut, Menu, LayoutGrid, LayoutDashboard, Newspaper, Megaphone, Award, MoreHorizontal } from 'lucide-react';
import GlobalSearch from './GlobalSearch';
import { AuthProvider, useAuth } from './AuthProvider';
import { ToastProvider } from './ToastProvider';
import { OrgProvider, useOrg } from './OrgProvider';
import Sidebar, { type TabId } from './Sidebar';
import NikkiShell from './NikkiShell';
import DashboardTab from './dashboard/DashboardTab';
import DraftsTab from './drafts/DraftsTab';
import PetitionsTab from './petitions/PetitionsTab';
import GrantsTab from './grants/GrantsTab';
import FundingExplorer from './grants/FundingExplorer';
import IntegrationsTab from './IntegrationsTab';
import ApisTab from './apis/ApisTab';
import NewsTab from './NewsTab';
import CypressAwardsTab from './CypressAwardsTab';
import CollaborationsTab from './collaborations/CollaborationsTab';
import PartnersTab from './partners/PartnersTab';
import OldLeadsTab from './leads/OldLeadsTab';
import HeyPartnerTab from './outreach/HeyPartnerTab';
import OutreachPlaybookTab from './outreach/OutreachPlaybookTab';
import CommunityTab from './community/CommunityTab';
import PetitionIdeas from './scraper/PetitionIdeas';
import XSessionsTab from './xsessions/XSessionsTab';
import LibraryTab from './library/LibraryTab';
import SettingsTab from './settings/SettingsTab';
import ZipReportCard from './geo/ZipReportCard';
import HotspotMap from './geo/HotspotMap';
import AdvocacyTargets from './geo/AdvocacyTargets';
import CrossRefExplorer from './geo/CrossRefExplorer';
import TrendPulse from './geo/TrendPulse';
import MoveOnImport from './geo/MoveOnImport';
import PulseFeed from './pulse/PulseFeed';
import IdeaMindMap from './graph/IdeaMindMap';
import PartnerTree from './graph/PartnerTree';
import ChatBar from './ChatBar';
import CongressTab from './congress/CongressTab';
import OpportunityMap from './congress/OpportunityMap';
import AgentAppsHub from './agents/AgentAppsHub';
import OrgOnboarding from './agents/OrgOnboarding';
import PetitionBuilder from './agents/PetitionBuilder';
import GrantFinder from './agents/GrantFinder';
import AdvocacyToolkit from './agents/AdvocacyToolkit';
import MoveOnLauncher from './petitions/MoveOnLauncher';
import PetitionCreator from './petitions/PetitionCreator';
import TrendingPetitions from './petitions/TrendingPetitions';
import PipelineTab from './pipeline/PipelineTab';
import TopicRadar from './radar/TopicRadar';
import StateChoropleth from './geo/StateChoropleth';
import TopicStateGrid from './geo/TopicStateGrid';
import StatementsTab from './statements/StatementsTab';
import PulseGlobe from './globe/PulseGlobe';
import EnhancedMindMap from './graph/EnhancedMindMap';
import DataSourcesTab from './datasource/DataSourcesTab';
import OnBoardTab from './onboard/OnBoardTab';
import JournalistHub from './journalists/JournalistHub';
import ContactsTab from './contacts/ContactsTab';
import NexusExplorer from './nexus/NexusExplorer';
import PowerMap from './nexus/PowerMap';
import OpportunityFinder from './nexus/OpportunityFinder';
import DeepInsights from './nexus/DeepInsights';
import PulseHeatMap from './pulse/PulseHeatMap';
import LiveNews from './news/LiveNews';
import VisualizationHub from './viz/VisualizationHub';
import ConnectionTree from './viz/ConnectionTree';
import CorrelationMatrix from './viz/CorrelationMatrix';
import NetworkGraph3D from './viz/NetworkGraph3D';
import SunburstChart from './viz/SunburstChart';
import OrganizationsTab from './orgs/OrganizationsTab';
import FoundationsTab from './foundations/FoundationsTab';
import DonationsTab from './donations/DonationsTab';
import OrgConnections from './connections/OrgConnections';
import PowerIntelligenceTab from './intelligence/PowerIntelligenceTab';
import InTheNewsTab from './intelligence/InTheNewsTab';
import EmailSendsTab from './email-sends/EmailSendsTab';
import EmailAnalyzer from './email-analyzer/EmailAnalyzer';
import SavedAnalyses from './email-analyzer/SavedAnalyses';
import GmailCRMTab from './gmail/GmailCRMTab';
// AgeThemeSlider + InboxThemeBanner imports removed — now live in app/layout.tsx
// via <TopThemeBar />. AppShell only carries authenticated-only widgets.
import ApiRegistryButton from './api-registry/ApiRegistryButton';
import ImpersonateMenu from './ImpersonateMenu';
import PriceTrackerTab from './price-tracker/PriceTrackerTab';
import SocialTab from './social/SocialTab';
/* ─── Inner shell (needs auth context) ──────────────────────────────────── */
const APP_LINKS = [
{ name: 'Hub', port: 7480, color: '#0891b2', icon: 'H' },
{ name: 'Norma', port: 7400, color: '#10b981', icon: 'N' },
{ name: 'Grant', port: 7450, color: '#059669', icon: 'G' },
{ name: 'Patty', port: 7460, color: '#7c3aed', icon: 'P' },
{ name: 'Freddy', port: 7470, color: '#d97706', icon: 'F' },
];
const CURRENT_PORT = 7400;
function Shell() {
const { user, role, logout } = useAuth();
const { org, orgs, switchOrg } = useOrg();
// Role-based shell routing
if (role === 'staff') {
return <NikkiShell />;
}
if (role === 'pulse') {
// Pulse users should be on /pulse route, redirect if they end up here
if (typeof window !== 'undefined') {
window.location.href = '/pulse';
}
return null;
}
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const [geoZip, setGeoZip] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showAppSwitcher, setShowAppSwitcher] = useState(false);
const [agentOrgId, setAgentOrgId] = useState('');
const [journalistNavId, setJournalistNavId] = useState<string | null>(null);
function handleTabChange(tab: TabId) {
setActiveTab(tab);
setSidebarOpen(false);
// Clear journalist nav ID on all normal tab changes (sidebar clicks)
setJournalistNavId(null);
}
/* ── Deep-link from Supervisor's GlobalSearch: ?tab=foo&id=bar ─────────── */
const searchParams = useSearchParams();
useEffect(() => {
const tab = searchParams.get('tab') as TabId | null;
const id = searchParams.get('id');
if (!tab) return;
// For journalist deep-links, set the journalistNavId so the JournalistsTab can focus it
if (tab === 'journalists' && id) setJournalistNavId(id);
setActiveTab(tab);
// Note: we don't strip the params from the URL — the user can refresh and land back here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ── Keyboard shortcuts for sidebar navigation ──────────────────────────── */
useEffect(() => {
const KBD_NAV: Record<string, TabId> = {
h: 'dashboard',
d: 'drafts',
p: 'petitions',
r: 'topic-radar',
l: 'pipeline',
n: 'contacts',
};
function handleKeyDown(e: KeyboardEvent) {
// Skip if modifier keys are held, or user is typing in an input/textarea/contenteditable
if (e.metaKey || e.ctrlKey || e.altKey) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if ((e.target as HTMLElement)?.isContentEditable) return;
const tab = KBD_NAV[e.key.toLowerCase()];
if (tab) {
e.preventDefault();
handleTabChange(tab);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
function renderPanel() {
/* ── No org selected — show welcome/org picker ─────────────────────── */
if (!org) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-6 max-w-lg mx-auto">
<div
style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, #10b981, #059669)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(16, 185, 129, 0.3)',
}}
>
<span style={{ color: '#fff', fontSize: 22, fontWeight: 900 }}>N</span>
</div>
<div className="text-center">
<h1 className="text-xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
Welcome to Norma
</h1>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Select an organization from the sidebar to get started. Each org has its own data, settings, and workflows.
</p>
</div>
{orgs.length > 0 && (
<div className="w-full space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-center" style={{ color: 'var(--color-text-muted)' }}>
Available Organizations
</p>
{orgs.map((o) => (
<button
key={o.id}
type="button"
onClick={() => switchOrg(o.id)}
className="w-full card"
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
cursor: 'pointer',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
textAlign: 'left',
transition: 'border-color 0.15s, background-color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = o.brand_color; e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.backgroundColor = 'var(--color-surface)'; }}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: `linear-gradient(135deg, ${o.brand_color}, ${o.brand_color}cc)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<span style={{ color: '#fff', fontSize: (o.short_name || '').length > 3 ? 9 : 13, fontWeight: 800 }}>
{(o.short_name || o.org_name.slice(0, 2)).slice(0, 4)}
</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', lineHeight: 1.2 }}>
{o.org_name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.3, marginTop: 2 }}>
{o.address_city}{o.address_city && o.address_state ? ', ' : ''}{o.address_state}
{o.focus_areas?.length > 0 ? ` — ${o.focus_areas.slice(0, 2).join(', ')}` : ''}
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
switch (activeTab) {
case 'dashboard': return <DashboardTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'drafts': return <DraftsTab onNavigateToSettings={() => handleTabChange('settings')} />;
case 'news': return <NewsTab onNavigate={(tab) => handleTabChange(tab as TabId)} onNavigateToJournalist={(name) => { setJournalistNavId(`name:${name}`); setActiveTab('journalists'); }} />;
case 'petitions': return <PetitionsTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'petition-ideas': return <PetitionIdeas />;
case 'grants': return <GrantsTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'funding': return <FundingExplorer />;
case 'collaborations': return <CollaborationsTab />;
case 'partners': return <PartnersTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'oldleads': return <OldLeadsTab />;
case 'heypartner': return <HeyPartnerTab />;
case 'playbook': return <OutreachPlaybookTab />;
case 'community': return <CommunityTab />;
case 'xsessions': return <XSessionsTab />;
case 'library': return <LibraryTab />;
case 'apis': return <ApisTab />;
case 'integrations': return <IntegrationsTab />;
case 'awards': return <CypressAwardsTab onNavigateToJournalist={(name) => { setJournalistNavId(`name:${name}`); setActiveTab('journalists'); }} />;
case 'settings': return <SettingsTab />;
case 'geo-report':
return (
<ZipReportCard
initialZip={geoZip}
onNavigateToZip={(zip) => { setGeoZip(zip); setActiveTab('geo-report'); }}
/>
);
case 'geo-hotspots':
return (
<HotspotMap
onNavigateToZip={(zip) => { setGeoZip(zip); setActiveTab('geo-report'); }}
/>
);
case 'geo-advocacy': return <AdvocacyTargets />;
case 'geo-scatter': return <CrossRefExplorer />;
case 'geo-trends': return <TrendPulse />;
case 'geo-moveon': return <MoveOnImport />;
case 'pulse-feed': return <PulseFeed />;
case 'pulse-globe': return <PulseGlobe />;
case 'pulse-mindmap': return <EnhancedMindMap />;
case 'pulse-datasources': return <DataSourcesTab />;
case 'pulse-onboard': return <OnBoardTab />;
case 'moveon-launch': return <MoveOnLauncher />;
case 'petition-creator': return <PetitionCreator />;
case 'trending-petitions': return <TrendingPetitions />;
case 'journalists': return <JournalistHub initialJournalistId={journalistNavId} />;
case 'contacts': return <ContactsTab />;
case 'topic-radar': return <TopicRadar onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'statements': return <StatementsTab />;
case 'pipeline': return <PipelineTab />;
case 'geo-choropleth': return <StateChoropleth />;
case 'geo-topic-grid': return <TopicStateGrid />;
case 'idea-mindmap': return <IdeaMindMap />;
case 'partner-tree': return <PartnerTree />;
case 'organizations': return <OrganizationsTab />;
case 'foundations': return <FoundationsTab />;
case 'donations': return <DonationsTab />;
case 'org-connections': return <OrgConnections />;
case 'congress': return <CongressTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'opportunity-map': return <OpportunityMap />;
case 'nexus-explorer': return <NexusExplorer />;
case 'nexus-power-map': return <PowerMap />;
case 'nexus-opportunities': return <OpportunityFinder onNavigateToJournalist={(id) => { setJournalistNavId(id); setActiveTab('journalists'); }} />;
case 'nexus-deep-insights': return <DeepInsights />;
case 'viz-hub': return <VisualizationHub />;
case 'viz-tree': return <ConnectionTree />;
case 'viz-matrix': return <CorrelationMatrix />;
case 'viz-3d': return <NetworkGraph3D />;
case 'viz-sunburst': return <SunburstChart />;
case 'pulse-heatmap': return <PulseHeatMap />;
case 'live-news': return <LiveNews />;
case 'power-intelligence': return <PowerIntelligenceTab />;
case 'in-the-news': return <InTheNewsTab />;
case 'email-sends': return <EmailSendsTab />;
case 'email-statements': return <EmailSendsTab initialType="statements" />;
case 'email-donations': return <EmailSendsTab initialType="donations" />;
case 'email-breaking': return <EmailSendsTab initialType="breaking" />;
case 'email-education': return <EmailSendsTab initialType="education" />;
case 'email-workshops': return <EmailSendsTab initialType="workshops" />;
case 'email-congress': return <EmailSendsTab initialType="congress" />;
case 'email-agencies': return <EmailSendsTab initialType="agencies" />;
case 'email-journalists': return <EmailSendsTab initialType="journalists" />;
case 'email-analyzer': return <EmailAnalyzer />;
case 'email-analyzer-saved':
return (
<SavedAnalyses
onOpen={(sid) => {
try { sessionStorage.setItem('norma-analyzer-load-session', sid); } catch { /* noop */ }
handleTabChange('email-analyzer');
}}
/>
);
case 'price-tracker': return <PriceTrackerTab />;
case 'social-overview': return <SocialTab initialPanel="overview" />;
case 'social-compose': return <SocialTab initialPanel="compose" />;
case 'social-schedule': return <SocialTab initialPanel="schedule" />;
case 'social-analytics': return <SocialTab initialPanel="analytics" />;
case 'social-competitors': return <SocialTab initialPanel="competitors" />;
case 'social-scoreboard': return <SocialTab initialPanel="scoreboard" />;
case 'social-accounts': return <SocialTab initialPanel="accounts" />;
case 'clients':
return (
<div className="p-5">
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Clients & Borrowers</h2>
<p className="text-sm mt-2" style={{ color: 'var(--color-text-muted)' }}>Constituent management coming soon.</p>
</div>
);
case 'gmail-crm': return <GmailCRMTab />;
case 'agent-hub':
return (
<AgentAppsHub
onNavigate={(tab) => handleTabChange(tab as TabId)}
selectedOrgId={agentOrgId}
onOrgChange={setAgentOrgId}
/>
);
case 'agent-onboard':
return (
<OrgOnboarding
onNavigate={(tab) => handleTabChange(tab as TabId)}
onOrgChange={setAgentOrgId}
/>
);
case 'agent-petition':
return (
<PetitionBuilder
selectedOrgId={agentOrgId}
onOrgChange={setAgentOrgId}
/>
);
case 'agent-grants':
return (
<GrantFinder
selectedOrgId={agentOrgId}
onOrgChange={setAgentOrgId}
/>
);
case 'agent-advocacy':
return (
<AdvocacyToolkit
selectedOrgId={agentOrgId}
onOrgChange={setAgentOrgId}
/>
);
default:
return (
<div className="flex flex-col items-center justify-center py-20 gap-3" style={{ color: 'var(--color-text-muted)' }}>
<p className="text-lg font-semibold">Tab not found</p>
<p className="text-sm">The selected view is not available.</p>
</div>
);
}
}
/* Tab label for the top bar */
const TAB_LABELS: Record<TabId, string> = {
dashboard: 'Dashboard',
drafts: 'Drafts',
news: 'News Feed',
petitions: 'Petitions',
'petition-ideas': 'Petition Ideas',
grants: 'Grants',
funding: 'AI Funding Explorer',
collaborations: 'Collaborations',
partners: 'Current Partners',
oldleads: 'Old Leads',
heypartner: 'Hey Partner',
playbook: 'Outreach Playbook',
community: 'Community Pulse',
xsessions: 'X Sessions',
library: 'Library',
apis: 'API Connections',
integrations: 'Integrations',
awards: 'Awards',
settings: 'Settings',
'geo-report': 'Zip Report Card',
'geo-hotspots': 'Hotspot Explorer',
'geo-advocacy': 'Advocacy Targets',
'geo-scatter': 'Cross-Reference Explorer',
'geo-trends': 'Trend Pulse',
'geo-moveon': 'MoveOn Petition Import',
'pulse-feed': 'Pulse of America',
'idea-mindmap': 'Idea Mind Map',
'partner-tree': 'Partner Discovery Tree',
organizations: 'Organizations',
foundations: 'Foundations',
donations: 'Donations',
congress: 'Congress Members',
'opportunity-map': 'Opportunity Map',
'agent-hub': 'Agent Apps Hub',
'agent-onboard': 'Organization Onboarding',
'agent-petition': 'AI Petition Builder',
'agent-grants': 'AI Grant Finder',
'agent-advocacy': 'Advocacy Toolkit',
'moveon-launch': 'MoveOn Petition Launcher',
'topic-radar': 'Topic Radar — AI-Suggested Petition Topics',
'pipeline': 'Petition Pipeline',
'geo-choropleth': 'State Heat Map',
'statements': 'Official Statements',
'geo-topic-grid': 'Topic Grid',
'pulse-globe': 'Pulse Globe — 3D World View',
'pulse-mindmap': 'Enhanced Mind Map — Cross-Modal Connections',
'pulse-datasources': 'Data Sources — APIs, Headlines & Statements',
'pulse-onboard': 'OnBoard — Non-Profit Discovery',
'petition-creator': 'Create Petition',
'trending-petitions': 'Trending Petitions',
'journalists': 'Journalist Intelligence Hub',
'contacts': 'Network — LinkedIn Contacts & Connections',
'nexus-explorer': 'Nexus Explorer — Cross-Entity Connections',
'nexus-power-map': 'Power Map — Politician Influence Rankings',
'nexus-opportunities': 'Opportunity Finder — Smart Petition Targeting',
'nexus-deep-insights': 'Deep Insights — Cross-Entity Intelligence',
'viz-hub': 'Visualization Hub — Interactive Connection Explorer',
'viz-tree': 'Connection Tree — Hierarchical Entity Relationships',
'viz-matrix': 'Correlation Matrix — Entity Type Interconnections',
'viz-3d': '3D Network — Spatial Connection Graph',
'viz-sunburst': 'Sunburst — Radial Connection Hierarchy',
'pulse-heatmap': 'Pulse Heat Map — Topic Intensity',
'live-news': 'Live News — Real-Time Coverage',
'org-connections': 'Organization Connections',
'power-intelligence': 'Power Intelligence — Weekly Contact Recommendations',
'in-the-news': 'In the News — Org Activity Feed',
'email-sends': 'Email & Outreach Center',
'email-statements': 'Email — Statements',
'email-donations': 'Email — Donation Requests',
'email-breaking': 'Email — Breaking News',
'email-education': 'Email — Education Only',
'email-workshops': 'Email — Workshops',
'email-congress': 'Email — Letters to Congress',
'email-agencies': 'Email — Letters to Agencies',
'email-journalists': 'Email — Letters to Journalists',
'email-analyzer': 'Email Analyzer — AI-Powered Rewriting',
'email-analyzer-saved': 'Email Analyzer — Saved Analyses',
'price-tracker': 'Price Tracker — Education Cost Intelligence',
'clients': 'Clients & Borrowers',
'gmail-crm': 'Gmail CRM — AI-Powered Inbox',
'social-overview': 'Social Media Hub',
'social-compose': 'Social Media — Compose',
'social-schedule': 'Social Media — Schedule',
'social-analytics': 'Social Media — Analytics',
'social-competitors': 'Social Media — Competitors',
'social-scoreboard': 'Social Media — Scoreboard',
'social-accounts': 'Social Media — Accounts',
};
return (
<div
className="flex"
style={{ backgroundColor: 'var(--color-bg)', minHeight: '100vh' }}
>
{/* ── Skip Navigation ───────────────────────────────────────────────── */}
<a
href="#main-content"
className="sr-only focus:not-sr-only"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 9999,
padding: '8px 16px',
backgroundColor: 'var(--color-primary)',
color: '#fff',
borderRadius: 'var(--radius-md)',
fontSize: '0.875rem',
fontWeight: 600,
textDecoration: 'none',
}}
>
Skip to main content
</a>
{/* ── Left Sidebar ─────────────────────────────────────────────────── */}
<Sidebar activeTab={activeTab} onTabChange={handleTabChange} isOpen={sidebarOpen} onToggle={() => setSidebarOpen(p => !p)} />
{/* ── Right: Header + Content ──────────────────────────────────────── */}
<div className="flex flex-col flex-1 min-w-0">
{/* ── Impersonation banner (shows ONLY while impersonating) ──────── */}
<ImpersonateMenu variant="banner" />
{/* Theme banner + age slider moved to app/layout.tsx so they render
on every page including /login (set both before authenticating). */}
{/* ── Top Header Bar ─────────────────────────────────────────────── */}
<header
className="flex items-center justify-between px-5 h-14 shrink-0"
style={{
backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}
>
{/* Left: hamburger + section name */}
<div className="flex items-center gap-2">
<button
className="btn btn-ghost btn-sm"
onClick={() => setSidebarOpen(p => !p)}
style={{ display: 'none' }}
aria-label="Toggle menu"
id="mobile-menu-btn"
>
<Menu size={18} />
</button>
<h1
className="text-sm font-semibold"
style={{ color: 'var(--color-text)' }}
>
{TAB_LABELS[activeTab]}
</h1>
</div>
{/* Center: global search */}
<GlobalSearch onNavigate={(tab, id) => {
if (tab === 'journalists' && id) {
setJournalistNavId(id);
}
setActiveTab(tab as TabId);
setSidebarOpen(false);
}} />
{/* Right: app switcher + user + logout */}
<div className="flex items-center gap-3">
{/* App Switcher */}
<div style={{ position: 'relative' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowAppSwitcher(p => !p)}
title="Switch app"
aria-label="Switch app"
>
<LayoutGrid size={16} />
</button>
{showAppSwitcher && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 49 }}
onClick={() => setShowAppSwitcher(false)}
/>
<div
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: 8,
padding: 8,
borderRadius: 12,
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
zIndex: 50,
minWidth: 200,
}}
>
<div style={{ padding: '4px 8px 8px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Norma Ecosystem
</div>
{APP_LINKS.map((app) => (
<a
key={app.name}
href={`http://45.61.58.125:${app.port}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 8,
textDecoration: 'none',
color: 'var(--color-text)',
fontSize: '0.8125rem',
fontWeight: 500,
transition: 'background-color 0.15s',
borderLeft: app.port === CURRENT_PORT ? `3px solid ${app.color}` : '3px solid transparent',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
onClick={() => setShowAppSwitcher(false)}
>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: `linear-gradient(135deg, ${app.color}, ${app.color}dd)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 12,
fontWeight: 800,
flexShrink: 0,
}}
>
{app.icon}
</div>
<div>
<div style={{ lineHeight: 1.2 }}>{app.name}</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2 }}>
Port {app.port}
</div>
</div>
</a>
))}
</div>
</>
)}
</div>
{user && (
<span
className="text-xs hidden sm:inline"
style={{ color: 'var(--color-text-muted)' }}
>
{user}
</span>
)}
{/* Inbox theme switcher moved to the top banner (see <InboxThemeBanner /> above). */}
{/* Admin: View-as another user (impersonation) */}
{role === 'admin' && <ImpersonateMenu variant="dropdown" />}
{/* APIs & Tokens registry — admin only */}
{role === 'admin' && <ApiRegistryButton variant="ghost" />}
<button
onClick={logout}
className="btn btn-ghost btn-sm"
aria-label="Sign out"
title="Sign out"
>
<LogOut size={15} aria-hidden="true" />
<span className="hidden sm:inline">Sign out</span>
</button>
</div>
</header>
{/* ── Main Content ───────────────────────────────────────────────── */}
<main id="main-content" className="flex-1 overflow-auto mobile-main-content">
{renderPanel()}
</main>
{/* ── Chat Bar (always visible at bottom) ───────────────────────── */}
<ChatBar />
</div>
{/* ── Mobile Bottom Navigation ─────────────────────────────────────── */}
<nav
className="mobile-bottom-nav"
aria-label="Mobile bottom navigation"
style={{
display: 'none',
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 56,
zIndex: 50,
backgroundColor: 'var(--color-surface)',
borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.08)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
height: '100%',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
>
{([
{ id: 'dashboard' as TabId, label: 'Dashboard', Icon: LayoutDashboard },
{ id: 'news' as TabId, label: 'News', Icon: Newspaper },
{ id: 'petitions' as TabId, label: 'Petitions', Icon: Megaphone },
{ id: 'grants' as TabId, label: 'Grants', Icon: Award },
] as const).map(({ id, label, Icon }) => {
const isActive = activeTab === id;
return (
<button
key={id}
type="button"
onClick={() => handleTabChange(id)}
aria-current={isActive ? 'page' : undefined}
aria-label={label}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
minHeight: 44,
height: '100%',
border: 'none',
background: 'none',
cursor: 'pointer',
color: isActive ? 'var(--color-primary)' : 'var(--color-text-muted)',
transition: 'color 0.15s',
position: 'relative',
}}
>
{isActive && (
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: '20%',
right: '20%',
height: 2,
borderRadius: '0 0 2px 2px',
background: 'linear-gradient(90deg, #34d399, #10b981)',
boxShadow: '0 0 6px rgba(52,211,153,0.6)',
}}
/>
)}
<Icon size={20} aria-hidden="true" />
<span style={{ fontSize: 10, fontWeight: isActive ? 700 : 500, lineHeight: 1 }}>
{label}
</span>
</button>
);
})}
{/* More — opens sidebar */}
<button
type="button"
onClick={() => setSidebarOpen(true)}
aria-label="Open navigation menu"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
minHeight: 44,
height: '100%',
border: 'none',
background: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted)',
transition: 'color 0.15s',
}}
>
<MoreHorizontal size={20} aria-hidden="true" />
<span style={{ fontSize: 10, fontWeight: 500, lineHeight: 1 }}>More</span>
</button>
</div>
</nav>
</div>
);
}
/* ─── Public export — wraps Shell in AuthProvider ────────────────────────── */
export default function AppShell() {
return (
<AuthProvider>
<ToastProvider>
<OrgProvider>
<Shell />
</OrgProvider>
</ToastProvider>
</AuthProvider>
);
}