dfd8579899
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Successful in 32s
CI / backend (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m16s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m12s
446 lines
20 KiB
React
446 lines
20 KiB
React
import { Link, NavLink, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import {
|
|
LayoutDashboard, Users, Sparkles, MailX, Search, RefreshCw, LogOut,
|
|
ChevronLeft, ChevronRight, ChevronDown, X, Plus, Wand2, ShieldCheck,
|
|
ScrollText, ListChecks,
|
|
} from 'lucide-react';
|
|
import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
|
|
import Logo from './Logo.jsx';
|
|
import DevBanner from './DevBanner.jsx';
|
|
import SyncStatus from './SyncStatus.jsx';
|
|
import DigestToggle from './DigestToggle.jsx';
|
|
import useSavedSearches from '../hooks/useSavedSearches.js';
|
|
import { cn } from '../lib/utils.js';
|
|
import {
|
|
Button, Input, Separator, ThemeToggle, Tooltip, TooltipContent, TooltipTrigger,
|
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
|
DropdownMenuLabel, DropdownMenuSeparator,
|
|
} from './ui';
|
|
|
|
// ── Folder definitions ────────────────────────────────────────────────────────
|
|
|
|
const MAILBOX = [
|
|
{ slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox' },
|
|
{ slug: 'allmail', icon: '📬', label: 'All Mail', countKey: 'allMail' },
|
|
{ slug: 'starred', icon: '⭐', label: 'Starred', countKey: 'starred' },
|
|
{ slug: 'sent', icon: '📤', label: 'Sent', countKey: 'sent' },
|
|
{ slug: 'drafts', icon: '✏️', label: 'Drafts', countKey: 'drafts' },
|
|
{ slug: 'archive', icon: '📦', label: 'Archive', countKey: null },
|
|
{ slug: 'spam', icon: '🚫', label: 'Spam', countKey: 'spam' },
|
|
{ slug: 'trash', icon: '🗑️', label: 'Trash', countKey: 'trash' },
|
|
{ slug: 'unlabeled', icon: '🏷️', label: 'Unlabeled', countKey: null },
|
|
{ slug: 'pinned', icon: '📌', label: 'Pinned', countKey: null },
|
|
{ slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null },
|
|
];
|
|
|
|
const FAV_SPECIALS = {
|
|
inbox: { slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox', type: 'mailbox' },
|
|
unread: { slug: 'unread', icon: '🔵', label: 'Unread Mail', countKey: 'unread', type: 'filter' },
|
|
large: { slug: 'large', icon: '📎', label: 'Large Mail', countKey: 'large', type: 'filter' },
|
|
old: { slug: 'old', icon: '🕰️', label: 'Old Mail', countKey: 'old', type: 'filter' },
|
|
readlater: { slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null, type: 'filter' },
|
|
};
|
|
|
|
const DEFAULT_FAV_SLUGS = ['inbox', 'unread', 'large', 'old', 'readlater'];
|
|
|
|
const SMART_FOLDERS = [
|
|
{ slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' },
|
|
{ slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' },
|
|
{ slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' },
|
|
{ slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' },
|
|
{ slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' },
|
|
{ slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' },
|
|
{ slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' },
|
|
{ slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' },
|
|
{ slug: 'social', icon: '📱', label: 'Social Notifications', countKey: 'social' },
|
|
{ slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' },
|
|
{ slug: 'travel', icon: '✈️', label: 'Travel', countKey: 'travel' },
|
|
{ slug: 'subscriptions',icon: '🔄', label: 'Subscriptions & SaaS', countKey: 'subscriptions' },
|
|
{ slug: 'parcels', icon: '📦', label: 'Parcels & Delivery', countKey: 'parcels' },
|
|
{ slug: 'recruitment', icon: '💼', label: 'Recruitment & Jobs', countKey: 'recruitment' },
|
|
{ slug: 'events', icon: '🎟️', label: 'Events & Tickets', countKey: 'events' },
|
|
{ slug: 'security', icon: '🔐', label: 'Security & Alerts', countKey: 'security' },
|
|
{ slug: 'healthcare', icon: '🏥', label: 'Healthcare', countKey: 'healthcare' },
|
|
{ slug: 'education', icon: '🎓', label: 'Education', countKey: 'education' },
|
|
{ slug: 'news', icon: '📰', label: 'News & Media', countKey: 'news' },
|
|
{ slug: 'property', icon: '🏠', label: 'Property & Utilities', countKey: 'property' },
|
|
{ slug: 'charity', icon: '❤️', label: 'Charities', countKey: 'charity' },
|
|
{ slug: 'government', icon: '🏛️', label: 'Government', countKey: 'government' },
|
|
{ slug: 'crypto', icon: '📈', label: 'Crypto & Investing', countKey: 'crypto' },
|
|
{ slug: 'family', icon: '👨👩👧', label: 'Family & School', countKey: 'family' },
|
|
];
|
|
|
|
// Primary app navigation (top of the sidebar). Automation pages are placeholders
|
|
// until their feature work lands (see docs/specs); links are kept here so the IA
|
|
// is visible, and they no-op gracefully to routes added later.
|
|
const PRIMARY_NAV = [
|
|
{ to: '/app', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
|
{ to: '/app/senders', label: 'Senders', icon: Users },
|
|
{ to: '/app/cleanup', label: 'Cleanup', icon: Sparkles },
|
|
{ to: '/app/unsubscribe', label: 'Unsubscribe', icon: MailX },
|
|
];
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
const fmtCount = (n) => {
|
|
if (!n) return null;
|
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
};
|
|
|
|
const LS_FAV = 'ii:fav-slugs';
|
|
const LS_OPEN = 'ii:sidebar-open';
|
|
const LS_SECTS = 'ii:sidebar-sections';
|
|
|
|
const loadFavs = () => {
|
|
try { return JSON.parse(localStorage.getItem(LS_FAV)) ?? DEFAULT_FAV_SLUGS; }
|
|
catch { return DEFAULT_FAV_SLUGS; }
|
|
};
|
|
const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v));
|
|
|
|
const loadSections = () => {
|
|
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true, saved: true }; }
|
|
catch { return { fav: true, mailbox: true, smart: true, saved: true }; }
|
|
};
|
|
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
|
|
|
|
// ── Sidebar sub-components ──────────────────────────────────────────────────────
|
|
|
|
function SectionHead({ label, open, onToggle, collapsed }) {
|
|
if (collapsed) return <Separator className="my-2" />;
|
|
return (
|
|
<button
|
|
onClick={onToggle}
|
|
className="flex w-full items-center justify-between px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80 transition-colors hover:text-muted-foreground"
|
|
>
|
|
<span>{label}</span>
|
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !open && '-rotate-90')} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function FolderRow({ item, active, count, collapsed, extra }) {
|
|
const content = (
|
|
<Link
|
|
to={`/app/folder/${item.slug}`}
|
|
className={cn(
|
|
'group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors',
|
|
collapsed && 'justify-center px-0',
|
|
active ? 'bg-primary/10 font-medium text-primary' : 'text-foreground/80 hover:bg-muted'
|
|
)}
|
|
>
|
|
<span className="text-base leading-none">{item.icon}</span>
|
|
{!collapsed && (
|
|
<>
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
{count && <span className="text-xs tabular-nums text-muted-foreground">{count}</span>}
|
|
{extra}
|
|
</>
|
|
)}
|
|
</Link>
|
|
);
|
|
if (collapsed) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// ── Main Layout ───────────────────────────────────────────────────────────────
|
|
|
|
export default function Layout() {
|
|
const [user, setUser] = useState(null);
|
|
const [searchParams] = useSearchParams();
|
|
const [searchQuery, setSearchQuery] = useState(() => searchParams.get('q') ?? '');
|
|
const [sidebarOpen, setSidebarOpen] = useState(() => {
|
|
try { return localStorage.getItem(LS_OPEN) !== 'false'; } catch { return true; }
|
|
});
|
|
const [sections, setSections] = useState(loadSections);
|
|
const [favSlugs, setFavSlugs] = useState(loadFavs);
|
|
const [counts, setCounts] = useState(null);
|
|
const { searches: savedSearches, remove: removeSavedSearch } = useSavedSearches();
|
|
|
|
const loc = useLocation();
|
|
const navigate = useNavigate();
|
|
const searchInputRef = useRef(null);
|
|
const collapsed = !sidebarOpen;
|
|
|
|
useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []);
|
|
useEffect(() => { AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {}); }, []);
|
|
|
|
// "/" focuses the search bar from anywhere, unless already typing in a field.
|
|
useEffect(() => {
|
|
const handler = (e) => {
|
|
if (e.key !== '/') return;
|
|
const tag = document.activeElement?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
e.preventDefault();
|
|
searchInputRef.current?.focus();
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, []);
|
|
|
|
const toggleSidebar = () => {
|
|
setSidebarOpen((o) => { localStorage.setItem(LS_OPEN, String(!o)); return !o; });
|
|
};
|
|
|
|
const toggleSection = (key) => {
|
|
setSections((s) => { const next = { ...s, [key]: !s[key] }; saveSections(next); return next; });
|
|
};
|
|
|
|
const toggleFav = useCallback((slug) => {
|
|
setFavSlugs((prev) => {
|
|
const next = prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug];
|
|
saveFavs(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const activeSlug = loc.pathname.startsWith('/app/folder/')
|
|
? loc.pathname.split('/app/folder/')[1]
|
|
: null;
|
|
|
|
const logout = async () => {
|
|
try { await AuthApi.logout(); } catch { /* ignore */ }
|
|
navigate('/');
|
|
};
|
|
|
|
const submitSearch = (e) => {
|
|
e.preventDefault();
|
|
const q = searchQuery.trim();
|
|
if (q) navigate(`/app/search?q=${encodeURIComponent(q)}`);
|
|
};
|
|
|
|
const startSync = async () => {
|
|
try { await SyncApi.incremental(); } catch { /* ignore */ }
|
|
if (loc.pathname !== '/app') navigate('/app');
|
|
window.dispatchEvent(new Event('inboxintel:sync-started'));
|
|
};
|
|
|
|
const mailboxCount = (item) => fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
|
const smartCount = (item) => fmtCount(counts?.smartFolders?.[item.slug]);
|
|
|
|
const favItems = favSlugs.map((slug) => {
|
|
if (FAV_SPECIALS[slug]) return { ...FAV_SPECIALS[slug], source: 'special' };
|
|
const sf = SMART_FOLDERS.find((f) => f.slug === slug);
|
|
return sf ? { ...sf, source: 'smart' } : null;
|
|
}).filter(Boolean);
|
|
|
|
const favCount = (item) =>
|
|
item.source === 'smart' ? smartCount(item) : fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
|
|
|
const sortedSmart = [...SMART_FOLDERS]
|
|
.filter((f) => !counts || (counts.smartFolders?.[f.slug] ?? 0) > 0)
|
|
.sort((a, b) => (counts?.smartFolders?.[b.slug] ?? 0) - (counts?.smartFolders?.[a.slug] ?? 0));
|
|
|
|
const isPinnedSmart = (slug) => favSlugs.includes(slug);
|
|
const userInitial = (user?.email?.[0] || '?').toUpperCase();
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
|
<DevBanner />
|
|
|
|
{/* ── Topbar ── */}
|
|
<header className="z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/80 px-4 backdrop-blur">
|
|
<Link to="/app" className="flex items-center gap-2 shrink-0">
|
|
<Logo size={26} withWordmark />
|
|
</Link>
|
|
|
|
<form className="relative ml-2 hidden flex-1 md:block" onSubmit={submitSearch}>
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
ref={searchInputRef}
|
|
type="search"
|
|
placeholder="Search your inbox…"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
aria-label="Search emails"
|
|
className="max-w-xl pl-9"
|
|
/>
|
|
</form>
|
|
|
|
<div className="flex flex-1 items-center justify-end gap-1.5 md:flex-initial">
|
|
<SyncStatus />
|
|
<DigestToggle />
|
|
<ThemeToggle />
|
|
<Button onClick={startSync} size="sm" className="gap-1.5">
|
|
<RefreshCw className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Sync now</span>
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="ml-1 flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label="Account menu"
|
|
>
|
|
{userInitial}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
|
<DropdownMenuLabel className="truncate normal-case">{user?.email || 'Signed in'}</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onSelect={logout} className="text-danger focus:bg-danger/10">
|
|
<LogOut className="h-4 w-4" /> Log out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* ── Sidebar ── */}
|
|
<aside
|
|
className={cn(
|
|
'flex shrink-0 flex-col overflow-y-auto border-r border-border bg-card transition-[width] duration-200',
|
|
collapsed ? 'w-16' : 'w-64'
|
|
)}
|
|
>
|
|
<nav className="flex flex-col gap-0.5 p-2">
|
|
{PRIMARY_NAV.map((n) => {
|
|
const Icon = n.icon;
|
|
const link = (
|
|
<NavLink
|
|
key={n.to}
|
|
to={n.to}
|
|
end={n.end}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
collapsed && 'justify-center px-0',
|
|
isActive ? 'bg-primary/10 text-primary' : 'text-foreground/80 hover:bg-muted'
|
|
)
|
|
}
|
|
>
|
|
<Icon className="h-[18px] w-[18px] shrink-0" />
|
|
{!collapsed && <span>{n.label}</span>}
|
|
</NavLink>
|
|
);
|
|
return collapsed ? (
|
|
<Tooltip key={n.to}>
|
|
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
<TooltipContent side="right">{n.label}</TooltipContent>
|
|
</Tooltip>
|
|
) : link;
|
|
})}
|
|
</nav>
|
|
|
|
<Separator />
|
|
|
|
<div className="flex-1 p-2">
|
|
{/* Favorites */}
|
|
<SectionHead label="Favorites" open={sections.fav} onToggle={() => toggleSection('fav')} collapsed={collapsed} />
|
|
{(sections.fav || collapsed) && (
|
|
<div className="flex flex-col gap-0.5">
|
|
{favItems.map((item) => (
|
|
<FolderRow
|
|
key={item.slug}
|
|
item={item}
|
|
active={activeSlug === item.slug}
|
|
count={favCount(item)}
|
|
collapsed={collapsed}
|
|
extra={item.source === 'smart' && (
|
|
<button
|
|
className="text-muted-foreground opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
|
|
title="Remove from Favorites"
|
|
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
|
|
><X className="h-3.5 w-3.5" /></button>
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Saved Searches */}
|
|
{savedSearches.length > 0 && (
|
|
<>
|
|
<SectionHead label="Saved Searches" open={sections.saved} onToggle={() => toggleSection('saved')} collapsed={collapsed} />
|
|
{!collapsed && sections.saved && (
|
|
<div className="flex flex-col gap-0.5">
|
|
{savedSearches.map((s) => (
|
|
<div key={s.id} className="group flex items-center">
|
|
<Link
|
|
to={`/app/search?q=${encodeURIComponent(s.query)}`}
|
|
title={s.query}
|
|
className={cn(
|
|
'flex flex-1 items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors',
|
|
loc.pathname === '/app/search' && searchParams.get('q') === s.query
|
|
? 'bg-primary/10 font-medium text-primary'
|
|
: 'text-foreground/80 hover:bg-muted'
|
|
)}
|
|
>
|
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="flex-1 truncate">{s.label}</span>
|
|
</Link>
|
|
<button
|
|
className="px-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
|
|
title="Remove saved search"
|
|
onClick={(e) => { e.preventDefault(); removeSavedSearch(s.id); }}
|
|
><X className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Mailbox */}
|
|
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={collapsed} />
|
|
{(sections.mailbox || collapsed) && (
|
|
<div className="flex flex-col gap-0.5">
|
|
{MAILBOX.map((item) => (
|
|
<FolderRow key={item.slug} item={item} active={activeSlug === item.slug} count={mailboxCount(item)} collapsed={collapsed} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Smart Folders */}
|
|
<SectionHead label="Smart Folders" open={sections.smart} onToggle={() => toggleSection('smart')} collapsed={collapsed} />
|
|
{(sections.smart || collapsed) && (
|
|
<div className="flex flex-col gap-0.5">
|
|
{sortedSmart.map((item) => (
|
|
<FolderRow
|
|
key={item.slug}
|
|
item={item}
|
|
active={activeSlug === item.slug}
|
|
count={smartCount(item)}
|
|
collapsed={collapsed}
|
|
extra={
|
|
<button
|
|
className={cn(
|
|
'text-muted-foreground transition-opacity hover:text-primary',
|
|
isPinnedSmart(item.slug) ? 'opacity-0 hover:text-danger group-hover:opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
)}
|
|
title={isPinnedSmart(item.slug) ? 'Remove from Favorites' : 'Add to Favorites'}
|
|
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
|
|
>{isPinnedSmart(item.slug) ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}</button>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Collapse toggle */}
|
|
<div className="border-t border-border p-2">
|
|
<Button variant="ghost" size={collapsed ? 'icon' : 'sm'} onClick={toggleSidebar} className={cn('w-full', collapsed && 'w-auto')}>
|
|
{collapsed ? <ChevronRight className="h-4 w-4" /> : <><ChevronLeft className="h-4 w-4" /> Collapse</>}
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ── Main content ── */}
|
|
<main className="flex-1 overflow-y-auto">
|
|
<div className="mx-auto max-w-7xl p-6 lg:p-8">
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|