feat(ui): F1+F2 — component primitives + rebuilt app shell

F1: shadcn-style primitive set in components/ui (Button, Card, Badge, Input,
Switch, Separator, Skeleton, Tooltip, DropdownMenu, Dialog, Sheet, Tabs, Table,
Toast+useToast, ThemeToggle, PageHeader/StatCard/EmptyState) on Radix + cva.

F2: Layout rebuilt on the new system — sidebar with primary nav (lucide icons)
+ favorites/saved-searches/mailbox/smart-folder sections, sticky topbar with
search, sync status, theme toggle, digest toggle, and an account dropdown. All
prior behavior preserved (auth, sync, collapse/section persistence, "/" search
focus). App wrapped in ToastProvider + TooltipProvider. Build green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 23:51:45 +02:00
parent 1bd8143a87
commit 9ae61432ba
19 changed files with 969 additions and 160 deletions
+220 -155
View File
@@ -1,11 +1,22 @@
import { Link, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
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 ────────────────────────────────────────────────────────
@@ -23,9 +34,8 @@ const MAILBOX = [
{ 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' },
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' },
@@ -45,7 +55,6 @@ const SMART_FOLDERS = [
{ slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' },
{ slug: 'social', icon: '📱', label: 'Social Notifications', countKey: 'social' },
{ slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' },
// New categories
{ slug: 'travel', icon: '✈️', label: 'Travel', countKey: 'travel' },
{ slug: 'subscriptions',icon: '🔄', label: 'Subscriptions & SaaS', countKey: 'subscriptions' },
{ slug: 'parcels', icon: '📦', label: 'Parcels & Delivery', countKey: 'parcels' },
@@ -62,6 +71,16 @@ const SMART_FOLDERS = [
{ 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) => {
@@ -85,35 +104,50 @@ const loadSections = () => {
};
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
// ── Sub-components ────────────────────────────────────────────────────────────
// ── Sidebar sub-components ──────────────────────────────────────────────────────
function SectionHead({ label, open, onToggle, collapsed }) {
if (collapsed) return <Separator className="my-2" />;
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
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 FolderLink({ item, active, count, collapsed, extra }) {
return (
function FolderRow({ item, active, count, collapsed, extra }) {
const content = (
<Link
to={`/app/folder/${item.slug}`}
className={`folder-item${active ? ' folder-item--active' : ''}`}
title={item.label}
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="folder-icon">{item.icon}</span>
<span className="text-base leading-none">{item.icon}</span>
{!collapsed && (
<>
<span className="folder-label">{item.label}</span>
<span className="folder-spacer" />
{count && <span className="folder-badge">{count}</span>}
<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 ───────────────────────────────────────────────────────────────
@@ -133,8 +167,10 @@ export default function Layout() {
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(() => {
@@ -149,23 +185,12 @@ export default function Layout() {
return () => window.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {});
}, []);
const toggleSidebar = () => {
setSidebarOpen((o) => {
localStorage.setItem(LS_OPEN, String(!o));
return !o;
});
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;
});
setSections((s) => { const next = { ...s, [key]: !s[key] }; saveSections(next); return next; });
};
const toggleFav = useCallback((slug) => {
@@ -176,14 +201,6 @@ export default function Layout() {
});
}, []);
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;
@@ -205,176 +222,224 @@ export default function Layout() {
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);
};
const favCount = (item) =>
item.source === 'smart' ? smartCount(item) : fmtCount(item.countKey ? counts?.[item.countKey] : null);
// Smart folders sorted by count desc; hide empty ones once counts have loaded
const sortedSmart = [...SMART_FOLDERS]
.filter((f) => !counts || (counts.smartFolders?.[f.slug] ?? 0) > 0)
.sort((a, b) => {
const ca = counts?.smartFolders?.[a.slug] ?? 0;
const cb = counts?.smartFolders?.[b.slug] ?? 0;
return cb - ca;
});
.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="app">
<div className="flex h-screen flex-col bg-background text-foreground">
<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>
<form className="search-form" onSubmit={submitSearch}>
<input
{/* ── 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}
className="search-input"
type="search"
placeholder="Search… (from:, is:unread, has:attachment)"
placeholder="Search… from: is:unread has:attachment ( / )"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search emails"
className="max-w-xl pl-9"
/>
<button type="submit" className="search-btn" aria-label="Search">🔍</button>
</form>
<div className="spacer" />
<SyncStatus />
<DigestToggle />
<button onClick={startSync}>Sync now</button>
<span className="user">{user?.email}</span>
<button className="ghost" onClick={logout}>Log out</button>
<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="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>
<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>
{/* ── 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
<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={!sidebarOpen}
extra={item.source === 'smart' && sidebarOpen && (
collapsed={collapsed}
extra={item.source === 'smart' && (
<button
className="fav-pin fav-pin--remove"
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); }}
></button>
><X className="h-3.5 w-3.5" /></button>
)}
/>
</li>
))}
</ul>
)}
))}
</div>
)}
{/* ── Saved Searches ── */}
{savedSearches.length > 0 && (
<>
<SectionHead label="Saved Searches" open={sections.saved} onToggle={() => toggleSection('saved')} collapsed={!sidebarOpen} />
{sections.saved && (
<ul className="folder-list">
{savedSearches.map((s) => (
<li key={s.id} className="saved-search-row">
<Link
to={`/app/search?q=${encodeURIComponent(s.query)}`}
className={`folder-item${loc.pathname === '/app/search' && searchParams.get('q') === s.query ? ' folder-item--active' : ''}`}
title={s.query}
>
<span className="folder-icon">🔎</span>
{sidebarOpen && <span className="folder-label">{s.label}</span>}
</Link>
{sidebarOpen && (
{/* 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="fav-pin fav-pin--remove"
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); }}
></button>
)}
</li>
))}
</ul>
)}
</>
)}
><X className="h-3.5 w-3.5" /></button>
</div>
))}
</div>
)}
</>
)}
{/* ── 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>
)}
{/* 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={!sidebarOpen} />
{sections.smart && (
<ul className="folder-list">
{sortedSmart.map((item) => (
<li key={item.slug}>
<FolderLink
{/* 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={!sidebarOpen}
extra={sidebarOpen && (
collapsed={collapsed}
extra={
<button
className={`fav-pin${isPinnedSmart(item.slug) ? ' fav-pin--remove' : ''}`}
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) ? '✕' : '⊕'}</button>
)}
>{isPinnedSmart(item.slug) ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}</button>
}
/>
</li>
))}
</ul>
)}
))}
</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>
<Outlet />
{/* ── 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 className="kbd-hint">
<kbd>/</kbd> search · <kbd>j</kbd>/<kbd>k</kbd> navigate · <kbd>e</kbd> archive · <kbd>#</kbd> trash
</div>
</div>
);
}