Files
Inboxintel/frontend/src/components/Layout.jsx
T
cesnimda ca02f40841 feat: Smart Folders sidebar, category heatmap, sidebar counts API
- 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>
2026-06-30 19:40:54 +02:00

290 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}