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:
+220
-155
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -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 <button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Card = forwardRef(function Card({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border border-border bg-card text-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const CardHeader = forwardRef(function CardHeader({ className, ...props }, ref) {
|
||||
return <div ref={ref} className={cn('flex flex-col gap-1 p-5', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardTitle = forwardRef(function CardTitle({ className, ...props }, ref) {
|
||||
return <h3 ref={ref} className={cn('text-base font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardDescription = forwardRef(function CardDescription({ className, ...props }, ref) {
|
||||
return <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardContent = forwardRef(function CardContent({ className, ...props }, ref) {
|
||||
return <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardFooter = forwardRef(function CardFooter({ className, ...props }, ref) {
|
||||
return <div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = forwardRef(function DialogOverlay({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm animate-fade-in', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const DialogContent = forwardRef(function DialogContent({ className, children, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-md animate-slide-up',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
function DialogHeader({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col gap-1.5 text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
|
||||
}
|
||||
|
||||
const DialogTitle = forwardRef(function DialogTitle({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />;
|
||||
});
|
||||
|
||||
const DialogDescription = forwardRef(function DialogDescription({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
});
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const contentClasses =
|
||||
'z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-foreground shadow-md animate-slide-up';
|
||||
|
||||
const DropdownMenuContent = forwardRef(function DropdownMenuContent(
|
||||
{ className, sideOffset = 4, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(contentClasses, className)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
const itemClasses =
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4';
|
||||
|
||||
const DropdownMenuItem = forwardRef(function DropdownMenuItem({ className, inset, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(itemClasses, inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem(
|
||||
{ className, children, checked, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
className={cn(itemClasses, 'pl-8', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuSubTrigger = forwardRef(function DropdownMenuSubTrigger(
|
||||
{ className, children, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(itemClasses, 'data-[state=open]:bg-muted', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuSubContent = forwardRef(function DropdownMenuSubContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent ref={ref} className={cn(contentClasses, className)} {...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuLabel = forwardRef(function DropdownMenuLabel({ className, inset, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuSeparator = forwardRef(function DropdownMenuSeparator({ className, ...props }, ref) {
|
||||
return <DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />;
|
||||
});
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// Barrel export for the UI primitive set. Import from '../components/ui'.
|
||||
export { Button, buttonVariants } from './button.jsx';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx';
|
||||
export { Badge, badgeVariants } from './badge.jsx';
|
||||
export { Input, Textarea } from './input.jsx';
|
||||
export { Switch } from './switch.jsx';
|
||||
export { Separator } from './separator.jsx';
|
||||
export { Skeleton } from './skeleton.jsx';
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './tooltip.jsx';
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
} from './dropdown-menu.jsx';
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog.jsx';
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from './sheet.jsx';
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs.jsx';
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './table.jsx';
|
||||
export { ToastProvider, useToast } from './toast.jsx';
|
||||
export { default as ThemeToggle } from './theme-toggle.jsx';
|
||||
export { PageHeader, StatCard, EmptyState } from './misc.jsx';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Input = forwardRef(function Input({ className, type = 'text', ...props }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm transition-colors',
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const Textarea = forwardRef(function Textarea({ className, ...props }, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex min-h-[72px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm shadow-sm transition-colors',
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Input, Textarea };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
/** Standard page title + optional description + right-aligned actions. */
|
||||
export function PageHeader({ title, description, actions, className }) {
|
||||
return (
|
||||
<div className={cn('mb-6 flex flex-wrap items-start justify-between gap-4', className)}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard metric tile. */
|
||||
export function StatCard({ label, value, hint, icon: Icon, tone = 'primary', className }) {
|
||||
const toneClass =
|
||||
tone === 'success'
|
||||
? 'text-success'
|
||||
: tone === 'warning'
|
||||
? 'text-warning'
|
||||
: tone === 'danger'
|
||||
? 'text-danger'
|
||||
: 'text-primary';
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border bg-card p-5 shadow-sm', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
{Icon && <Icon className={cn('h-4 w-4', toneClass)} />}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight">{value}</div>
|
||||
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty / zero-state placeholder for lists and tables. */
|
||||
export function EmptyState({ icon: Icon, title, description, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-10 text-center', className)}>
|
||||
{Icon && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Separator = forwardRef(function Separator(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
// A side drawer built on Radix Dialog. Used for the rule editor, filters, etc.
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
const sideClasses = {
|
||||
right: 'inset-y-0 right-0 h-full w-full max-w-md border-l animate-slide-in-right',
|
||||
left: 'inset-y-0 left-0 h-full w-full max-w-md border-r',
|
||||
};
|
||||
|
||||
const SheetContent = forwardRef(function SheetContent({ className, children, side = 'right', ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm animate-fade-in" />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-card p-6 shadow-md border-border',
|
||||
sideClasses[side],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
function SheetHeader({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col gap-1.5', className)} {...props} />;
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }) {
|
||||
return <div className={cn('mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
|
||||
}
|
||||
|
||||
const SheetTitle = forwardRef(function SheetTitle({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />;
|
||||
});
|
||||
|
||||
const SheetDescription = forwardRef(function SheetDescription({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
function Skeleton({ className, ...props }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Switch = forwardRef(function Switch({ className, ...props }, ref) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Table = forwardRef(function Table({ className, ...props }, ref) {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TableHeader = forwardRef(function TableHeader({ className, ...props }, ref) {
|
||||
return <thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border', className)} {...props} />;
|
||||
});
|
||||
|
||||
const TableBody = forwardRef(function TableBody({ className, ...props }, ref) {
|
||||
return <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
|
||||
});
|
||||
|
||||
const TableRow = forwardRef(function TableRow({ className, ...props }, ref) {
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TableHead = forwardRef(function TableHead({ className, ...props }, ref) {
|
||||
return (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn('h-10 px-3 text-left align-middle text-xs font-semibold uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TableCell = forwardRef(function TableCell({ className, ...props }, ref) {
|
||||
return <td ref={ref} className={cn('px-3 py-2.5 align-middle', className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = forwardRef(function TabsList({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-9 items-center justify-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabsTrigger = forwardRef(function TabsTrigger({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabsContent = forwardRef(function TabsContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import useTheme from '../../hooks/useTheme.js';
|
||||
import { Button } from './button.jsx';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip.jsx';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle theme">
|
||||
{isDark ? <Sun className="h-[18px] w-[18px]" /> : <Moon className="h-[18px] w-[18px]" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isDark ? 'Switch to light' : 'Switch to dark'}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import { X, CheckCircle2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
let idSeq = 0;
|
||||
|
||||
const ICONS = {
|
||||
success: CheckCircle2,
|
||||
danger: AlertTriangle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap the app once. Exposes useToast().toast({ title, description, variant }).
|
||||
* Replaces the ad-hoc per-page toast state scattered across the old pages.
|
||||
*/
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const dismiss = useCallback((id) => setToasts((t) => t.filter((x) => x.id !== id)), []);
|
||||
|
||||
const toast = useCallback(({ title, description, variant = 'info', duration = 4000 }) => {
|
||||
const id = ++idSeq;
|
||||
setToasts((t) => [...t, { id, title, description, variant, duration }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||
<ToastPrimitive.Provider swipeDirection="right" duration={4000}>
|
||||
{children}
|
||||
{toasts.map(({ id, title, description, variant, duration }) => {
|
||||
const Icon = ICONS[variant] || Info;
|
||||
const tone =
|
||||
variant === 'success'
|
||||
? 'text-success'
|
||||
: variant === 'danger'
|
||||
? 'text-danger'
|
||||
: variant === 'warning'
|
||||
? 'text-warning'
|
||||
: 'text-primary';
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
key={id}
|
||||
duration={duration}
|
||||
onOpenChange={(open) => !open && dismiss(id)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border bg-card p-4 shadow-md',
|
||||
'animate-slide-up data-[state=closed]:animate-fade-in'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', tone)} />
|
||||
<div className="flex-1">
|
||||
{title && <ToastPrimitive.Title className="text-sm font-semibold">{title}</ToastPrimitive.Title>}
|
||||
{description && (
|
||||
<ToastPrimitive.Description className="mt-0.5 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</ToastPrimitive.Description>
|
||||
)}
|
||||
</div>
|
||||
<ToastPrimitive.Close className="text-muted-foreground transition-colors hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitive.Close>
|
||||
</ToastPrimitive.Root>
|
||||
);
|
||||
})}
|
||||
<ToastPrimitive.Viewport className="fixed bottom-0 right-0 z-[100] flex w-full max-w-sm flex-col gap-2 p-4 outline-none" />
|
||||
</ToastPrimitive.Provider>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = forwardRef(function TooltipContent(
|
||||
{ className, sideOffset = 6, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-md',
|
||||
'animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
+10
-5
@@ -9,13 +9,16 @@ import Unsubscribe from './pages/Unsubscribe.jsx';
|
||||
import FolderView from './pages/FolderView.jsx';
|
||||
import SearchResults from './pages/SearchResults.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import { ToastProvider, TooltipProvider } from './components/ui';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public landing page */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
|
||||
@@ -29,8 +32,10 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<Route path="search" element={<SearchResults />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user