From 9ae61432ba0db624dd69b42b5f9ee4be5242f301 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 23:51:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20F1+F2=20=E2=80=94=20component=20pri?= =?UTF-8?q?mitives=20+=20rebuilt=20app=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/Layout.jsx | 375 +++++++++++-------- frontend/src/components/ui/badge.jsx | 25 ++ frontend/src/components/ui/button.jsx | 34 ++ frontend/src/components/ui/card.jsx | 34 ++ frontend/src/components/ui/dialog.jsx | 67 ++++ frontend/src/components/ui/dropdown-menu.jsx | 118 ++++++ frontend/src/components/ui/index.js | 48 +++ frontend/src/components/ui/input.jsx | 35 ++ frontend/src/components/ui/misc.jsx | 54 +++ frontend/src/components/ui/separator.jsx | 24 ++ frontend/src/components/ui/sheet.jsx | 55 +++ frontend/src/components/ui/skeleton.jsx | 7 + frontend/src/components/ui/switch.jsx | 23 ++ frontend/src/components/ui/table.jsx | 44 +++ frontend/src/components/ui/tabs.jsx | 42 +++ frontend/src/components/ui/theme-toggle.jsx | 19 + frontend/src/components/ui/toast.jsx | 81 ++++ frontend/src/components/ui/tooltip.jsx | 29 ++ frontend/src/main.jsx | 15 +- 19 files changed, 969 insertions(+), 160 deletions(-) create mode 100644 frontend/src/components/ui/badge.jsx create mode 100644 frontend/src/components/ui/button.jsx create mode 100644 frontend/src/components/ui/card.jsx create mode 100644 frontend/src/components/ui/dialog.jsx create mode 100644 frontend/src/components/ui/dropdown-menu.jsx create mode 100644 frontend/src/components/ui/index.js create mode 100644 frontend/src/components/ui/input.jsx create mode 100644 frontend/src/components/ui/misc.jsx create mode 100644 frontend/src/components/ui/separator.jsx create mode 100644 frontend/src/components/ui/sheet.jsx create mode 100644 frontend/src/components/ui/skeleton.jsx create mode 100644 frontend/src/components/ui/switch.jsx create mode 100644 frontend/src/components/ui/table.jsx create mode 100644 frontend/src/components/ui/tabs.jsx create mode 100644 frontend/src/components/ui/theme-toggle.jsx create mode 100644 frontend/src/components/ui/toast.jsx create mode 100644 frontend/src/components/ui/tooltip.jsx diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index bbcac11..1768a14 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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 ; return ( - ); } -function FolderLink({ item, active, count, collapsed, extra }) { - return ( +function FolderRow({ item, active, count, collapsed, extra }) { + const content = ( - {item.icon} + {item.icon} {!collapsed && ( <> - {item.label} - - {count && {count}} + {item.label} + {count && {count}} {extra} )} ); + if (collapsed) { + return ( + + {content} + {item.label} + + ); + } + 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 ( -
+
-
- - -
- + + + + + + + setSearchQuery(e.target.value)} aria-label="Search emails" + className="max-w-xl pl-9" /> - -
- - - - {user?.email} - + +
+ + + + + + + + + + {user?.email || 'Signed in'} + + + Log out + + + +
-
-
+ ))} +
+ )} + + )} - {/* ── Mailbox ── */} - toggleSection('mailbox')} collapsed={!sidebarOpen} /> - {sections.mailbox && ( -
    - {MAILBOX.map((item) => ( -
  • - -
  • - ))} -
- )} + {/* Mailbox */} + toggleSection('mailbox')} collapsed={collapsed} /> + {(sections.mailbox || collapsed) && ( +
+ {MAILBOX.map((item) => ( + + ))} +
+ )} - {/* ── Smart Folders ── */} - toggleSection('smart')} collapsed={!sidebarOpen} /> - {sections.smart && ( -
    - {sortedSmart.map((item) => ( -
  • - toggleSection('smart')} collapsed={collapsed} /> + {(sections.smart || collapsed) && ( +
    + {sortedSmart.map((item) => ( + { e.preventDefault(); toggleFav(item.slug); }} - >{isPinnedSmart(item.slug) ? '✕' : '⊕'} - )} + >{isPinnedSmart(item.slug) ? : } + } /> -
  • - ))} -
- )} + ))} +
+ )} + + + {/* Collapse toggle */} +
+ +
-
- + {/* ── Main content ── */} +
+
+ +
- -
- / search · j/k navigate · e archive · # trash -
); } diff --git a/frontend/src/components/ui/badge.jsx b/frontend/src/components/ui/badge.jsx new file mode 100644 index 0000000..6fed8db --- /dev/null +++ b/frontend/src/components/ui/badge.jsx @@ -0,0 +1,25 @@ +import { cva } from 'class-variance-authority'; +import { cn } from '../../lib/utils.js'; + +const badgeVariants = cva( + 'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors', + { + variants: { + variant: { + default: 'border-transparent bg-muted text-muted-foreground', + primary: 'border-transparent bg-primary/10 text-primary', + success: 'border-transparent bg-success/10 text-success', + warning: 'border-transparent bg-warning/15 text-warning', + danger: 'border-transparent bg-danger/10 text-danger', + outline: 'border-border text-foreground', + }, + }, + defaultVariants: { variant: 'default' }, + } +); + +function Badge({ className, variant, ...props }) { + return ; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/button.jsx b/frontend/src/components/ui/button.jsx new file mode 100644 index 0000000..913ceed --- /dev/null +++ b/frontend/src/components/ui/button.jsx @@ -0,0 +1,34 @@ +import { forwardRef } from 'react'; +import { cva } from 'class-variance-authority'; +import { cn } from '../../lib/utils.js'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + primary: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm', + secondary: 'bg-muted text-foreground hover:bg-muted/70', + outline: 'border border-border bg-card hover:bg-muted text-foreground', + ghost: 'hover:bg-muted text-foreground', + danger: 'bg-danger text-danger-foreground hover:bg-danger/90 shadow-sm', + 'danger-outline': 'border border-danger/40 text-danger hover:bg-danger/10', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + sm: 'h-8 px-3 text-xs', + md: 'h-9 px-4', + lg: 'h-10 px-6', + icon: 'h-9 w-9', + 'icon-sm': 'h-8 w-8', + }, + }, + defaultVariants: { variant: 'primary', size: 'md' }, + } +); + +const Button = forwardRef(function Button({ className, variant, size, ...props }, ref) { + return