ca02f40841
- Replace plain activity heatmap with CategoryHeatmapWidget on dashboard - Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections - Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage - Mailbox section: 11 Gmail mailbox items with icons and live count badges - Smart Folders section: 10 category folders sorted by count desc - Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint - Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping - Sidebar collapses to icon-only; section states persist to localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
290 lines
12 KiB
React
290 lines
12 KiB
React
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||
import { useEffect, useState, useCallback } from 'react';
|
||
import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
|
||
import Logo from './Logo.jsx';
|
||
import DevBanner from './DevBanner.jsx';
|
||
|
||
// ── 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 },
|
||
];
|
||
|
||
// Special items always available as favorites (not smart folders)
|
||
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' },
|
||
];
|
||
|
||
// ── 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 }; }
|
||
catch { return { fav: true, mailbox: true, smart: true }; }
|
||
};
|
||
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
|
||
|
||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||
|
||
function SectionHead({ label, open, onToggle, collapsed }) {
|
||
return (
|
||
<button className="section-head" onClick={onToggle} title={label}>
|
||
{!collapsed && <span className="section-title">{label}</span>}
|
||
<span className={`section-arrow${open ? '' : ' section-arrow--closed'}`}>›</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function FolderLink({ item, active, count, collapsed, extra }) {
|
||
return (
|
||
<Link
|
||
to={`/app/folder/${item.slug}`}
|
||
className={`folder-item${active ? ' folder-item--active' : ''}`}
|
||
title={item.label}
|
||
>
|
||
<span className="folder-icon">{item.icon}</span>
|
||
{!collapsed && (
|
||
<>
|
||
<span className="folder-label">{item.label}</span>
|
||
<span className="folder-spacer" />
|
||
{count && <span className="folder-badge">{count}</span>}
|
||
{extra}
|
||
</>
|
||
)}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ── Main Layout ───────────────────────────────────────────────────────────────
|
||
|
||
export default function Layout() {
|
||
const [user, setUser] = useState(null);
|
||
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 loc = useLocation();
|
||
const navigate = useNavigate();
|
||
|
||
useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []);
|
||
|
||
useEffect(() => {
|
||
AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {});
|
||
}, []);
|
||
|
||
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 nav = [
|
||
{ to: '/app', label: 'Dashboard', end: true },
|
||
{ to: '/app/senders', label: 'Senders' },
|
||
{ to: '/app/cleanup', label: 'Cleanup' },
|
||
{ to: '/app/unsubscribe', label: 'Unsubscribe' }
|
||
];
|
||
|
||
const isNavActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to));
|
||
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 startSync = async () => {
|
||
try { await SyncApi.incremental(); } catch { /* ignore */ }
|
||
if (loc.pathname !== '/app') navigate('/app');
|
||
window.dispatchEvent(new Event('inboxintel:sync-started'));
|
||
};
|
||
|
||
// Count getters
|
||
const mailboxCount = (item) => fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
||
const smartCount = (item) => fmtCount(counts?.smartFolders?.[item.slug]);
|
||
|
||
// Build favorites list: specials first (in default order), then pinned smart folders
|
||
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) => {
|
||
if (item.source === 'smart') return smartCount(item);
|
||
return fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
||
};
|
||
|
||
// Smart folders sorted by count desc
|
||
const sortedSmart = [...SMART_FOLDERS].sort((a, b) => {
|
||
const ca = counts?.smartFolders?.[a.slug] ?? 0;
|
||
const cb = counts?.smartFolders?.[b.slug] ?? 0;
|
||
return cb - ca;
|
||
});
|
||
|
||
const isPinnedSmart = (slug) => favSlugs.includes(slug);
|
||
|
||
return (
|
||
<div className="app">
|
||
<DevBanner />
|
||
<header className="topbar">
|
||
<Link to="/app" className="brand-link"><Logo size={28} withWordmark /></Link>
|
||
<nav>
|
||
{nav.map((n) => (
|
||
<Link key={n.to} to={n.to} className={isNavActive(n) ? 'active' : ''}>{n.label}</Link>
|
||
))}
|
||
</nav>
|
||
<div className="spacer" />
|
||
<button onClick={startSync}>Sync now</button>
|
||
<span className="user">{user?.email}</span>
|
||
<button className="ghost" onClick={logout}>Log out</button>
|
||
</header>
|
||
|
||
<div className="app-body">
|
||
<aside className={`sidebar${sidebarOpen ? '' : ' sidebar--collapsed'}`}>
|
||
<div className="sidebar-header">
|
||
{sidebarOpen && <span className="sidebar-brand">Folders</span>}
|
||
<button className="sidebar-toggle ghost" onClick={toggleSidebar} title={sidebarOpen ? 'Collapse' : 'Expand'}>
|
||
{sidebarOpen ? '‹' : '›'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Favorites ── */}
|
||
<SectionHead label="Favorites" open={sections.fav} onToggle={() => toggleSection('fav')} collapsed={!sidebarOpen} />
|
||
{sections.fav && (
|
||
<ul className="folder-list">
|
||
{favItems.map((item) => (
|
||
<li key={item.slug}>
|
||
<FolderLink
|
||
item={item}
|
||
active={activeSlug === item.slug}
|
||
count={favCount(item)}
|
||
collapsed={!sidebarOpen}
|
||
extra={item.source === 'smart' && sidebarOpen && (
|
||
<button
|
||
className="fav-pin fav-pin--remove"
|
||
title="Remove from Favorites"
|
||
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
|
||
>✕</button>
|
||
)}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{/* ── Mailbox ── */}
|
||
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={!sidebarOpen} />
|
||
{sections.mailbox && (
|
||
<ul className="folder-list">
|
||
{MAILBOX.map((item) => (
|
||
<li key={item.slug}>
|
||
<FolderLink
|
||
item={item}
|
||
active={activeSlug === item.slug}
|
||
count={mailboxCount(item)}
|
||
collapsed={!sidebarOpen}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{/* ── Smart Folders ── */}
|
||
<SectionHead label="Smart Folders" open={sections.smart} onToggle={() => toggleSection('smart')} collapsed={!sidebarOpen} />
|
||
{sections.smart && (
|
||
<ul className="folder-list">
|
||
{sortedSmart.map((item) => (
|
||
<li key={item.slug}>
|
||
<FolderLink
|
||
item={item}
|
||
active={activeSlug === item.slug}
|
||
count={smartCount(item)}
|
||
collapsed={!sidebarOpen}
|
||
extra={sidebarOpen && (
|
||
<button
|
||
className={`fav-pin${isPinnedSmart(item.slug) ? ' fav-pin--remove' : ''}`}
|
||
title={isPinnedSmart(item.slug) ? 'Remove from Favorites' : 'Add to Favorites'}
|
||
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
|
||
>{isPinnedSmart(item.slug) ? '✕' : '⊕'}</button>
|
||
)}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</aside>
|
||
|
||
<main>
|
||
<Outlet />
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|