← back to Patty
components/AppShell.tsx
255 lines
'use client';
import { useState, useEffect } from 'react';
import { LogOut, Menu, LayoutGrid } from 'lucide-react';
import { AuthProvider, useAuth } from './AuthProvider';
import { ToastProvider } from './ToastProvider';
import Sidebar, { type TabId } from './Sidebar';
import DashboardTab from './dashboard/DashboardTab';
import NewsFeedTab from './news/NewsFeedTab';
import TopicsTab from './topics/TopicsTab';
import PetitionsTab from './petitions/PetitionsTab';
import GrantsTab from './grants/GrantsTab';
import HeatMapTab from './heatmap/HeatMapTab';
import CampaignsTab from './campaigns/CampaignsTab';
import SourcesTab from './sources/SourcesTab';
import SettingsTab from './settings/SettingsTab';
const APP_LINKS = [
{ name: 'Hub', port: 7480, color: '#0891b2', icon: 'H' },
{ name: 'SDCC', port: 7400, color: '#3b82f6', icon: 'S' },
{ 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 = 7460;
/* ─── Inner shell (needs auth context) ──────────────────────────────────── */
function Shell() {
const { user, logout, isAdmin, role, canUseAI } = useAuth();
// Tier badge for non-admin (guest) accounts so the limited mode is obvious.
const accountBadge = isAdmin
? null
: role === 'member'
? { label: 'Guest', color: '#3b82f6' }
: canUseAI
? { label: 'Demo · read-only', color: '#7c3aed' }
: { label: 'Read-only', color: '#6b7280' };
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showAppSwitcher, setShowAppSwitcher] = useState(false);
const [selectedPetitionId, setSelectedPetitionId] = useState<number | null>(null);
const [selectedGrantId, setSelectedGrantId] = useState<string | null>(null);
function handleTabChange(tab: TabId) {
setActiveTab(tab);
setSidebarOpen(false);
}
function handleViewPetition(id: number) {
setSelectedPetitionId(id);
setActiveTab('petitions');
}
function handleViewGrant(id: string) {
setSelectedGrantId(id);
setActiveTab('grants');
}
function renderPanel() {
switch (activeTab) {
case 'dashboard': return <DashboardTab />;
case 'newsfeed': return <NewsFeedTab />;
case 'topics': return <TopicsTab onViewPetition={handleViewPetition} onViewGrant={handleViewGrant} />;
case 'petitions': return <PetitionsTab selectedId={selectedPetitionId} onSelectedConsumed={() => setSelectedPetitionId(null)} onViewGrant={handleViewGrant} />;
case 'grants': return <GrantsTab selectedId={selectedGrantId} onSelectedConsumed={() => setSelectedGrantId(null)} />;
case 'heatmap': return <HeatMapTab />;
case 'campaigns': return <CampaignsTab />;
case 'sources': return <SourcesTab />;
case 'settings': return <SettingsTab />;
}
}
const TAB_LABELS: Record<TabId, string> = {
dashboard: 'Dashboard',
newsfeed: 'News Feed',
topics: 'Topics',
petitions: 'Petitions',
grants: 'Grants',
heatmap: 'Heat Map',
campaigns: 'Campaigns',
sources: 'Sources',
settings: 'Settings',
};
return (
<div
className="flex"
style={{ backgroundColor: 'var(--color-bg)', minHeight: '100vh' }}
>
<Sidebar activeTab={activeTab} onTabChange={handleTabChange} isOpen={sidebarOpen} onToggle={() => setSidebarOpen(p => !p)} />
<div className="flex flex-col flex-1 min-w-0">
<header
className="flex items-center justify-between px-5 h-14 shrink-0"
style={{
backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}
>
<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>
<div className="flex items-center gap-3">
<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.4)',
zIndex: 50,
minWidth: 200,
}}
>
<div style={{ padding: '4px 8px 8px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
SDCC 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>
{accountBadge && (
<span
title={`Signed in as a ${accountBadge.label} account`}
style={{
display: 'inline-flex', alignItems: 'center', padding: '3px 9px',
borderRadius: 9999, fontSize: '0.7rem', fontWeight: 700,
letterSpacing: '0.02em', textTransform: 'uppercase', color: '#fff',
backgroundColor: accountBadge.color,
}}
>
{accountBadge.label}
</span>
)}
{user && (
<span
className="text-xs hidden sm:inline"
style={{ color: 'var(--color-text-muted)' }}
>
{user}
</span>
)}
<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 className="flex-1 overflow-auto">
{renderPanel()}
</main>
</div>
</div>
);
}
export default function AppShell() {
return (
<AuthProvider>
<ToastProvider>
<Shell />
</ToastProvider>
</AuthProvider>
);
}