-
-
-
);
}
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
;
+});
+
+export { Button, buttonVariants };
diff --git a/frontend/src/components/ui/card.jsx b/frontend/src/components/ui/card.jsx
new file mode 100644
index 0000000..91e4c00
--- /dev/null
+++ b/frontend/src/components/ui/card.jsx
@@ -0,0 +1,34 @@
+import { forwardRef } from 'react';
+import { cn } from '../../lib/utils.js';
+
+const Card = forwardRef(function Card({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+const CardHeader = forwardRef(function CardHeader({ className, ...props }, ref) {
+ return
;
+});
+
+const CardTitle = forwardRef(function CardTitle({ className, ...props }, ref) {
+ return
;
+});
+
+const CardDescription = forwardRef(function CardDescription({ className, ...props }, ref) {
+ return
;
+});
+
+const CardContent = forwardRef(function CardContent({ className, ...props }, ref) {
+ return
;
+});
+
+const CardFooter = forwardRef(function CardFooter({ className, ...props }, ref) {
+ return
;
+});
+
+export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
diff --git a/frontend/src/components/ui/dialog.jsx b/frontend/src/components/ui/dialog.jsx
new file mode 100644
index 0000000..6f32d53
--- /dev/null
+++ b/frontend/src/components/ui/dialog.jsx
@@ -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 (
+
+ );
+});
+
+const DialogContent = forwardRef(function DialogContent({ className, children, ...props }, ref) {
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ );
+});
+
+function DialogHeader({ className, ...props }) {
+ return
;
+}
+
+function DialogFooter({ className, ...props }) {
+ return
;
+}
+
+const DialogTitle = forwardRef(function DialogTitle({ className, ...props }, ref) {
+ return
;
+});
+
+const DialogDescription = forwardRef(function DialogDescription({ className, ...props }, ref) {
+ return
;
+});
+
+export {
+ Dialog,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/frontend/src/components/ui/dropdown-menu.jsx b/frontend/src/components/ui/dropdown-menu.jsx
new file mode 100644
index 0000000..f5bb074
--- /dev/null
+++ b/frontend/src/components/ui/dropdown-menu.jsx
@@ -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 (
+
+
+
+ );
+});
+
+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 (
+
+ );
+});
+
+const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem(
+ { className, children, checked, ...props },
+ ref
+) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+});
+
+const DropdownMenuSubTrigger = forwardRef(function DropdownMenuSubTrigger(
+ { className, children, ...props },
+ ref
+) {
+ return (
+
+ {children}
+
+
+ );
+});
+
+const DropdownMenuSubContent = forwardRef(function DropdownMenuSubContent({ className, ...props }, ref) {
+ return (
+
+
+
+ );
+});
+
+const DropdownMenuLabel = forwardRef(function DropdownMenuLabel({ className, inset, ...props }, ref) {
+ return (
+
+ );
+});
+
+const DropdownMenuSeparator = forwardRef(function DropdownMenuSeparator({ className, ...props }, ref) {
+ return
;
+});
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/frontend/src/components/ui/index.js b/frontend/src/components/ui/index.js
new file mode 100644
index 0000000..e5d502d
--- /dev/null
+++ b/frontend/src/components/ui/index.js
@@ -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';
diff --git a/frontend/src/components/ui/input.jsx b/frontend/src/components/ui/input.jsx
new file mode 100644
index 0000000..6d2da0c
--- /dev/null
+++ b/frontend/src/components/ui/input.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 (
+
+ );
+});
+
+const Textarea = forwardRef(function Textarea({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export { Input, Textarea };
diff --git a/frontend/src/components/ui/misc.jsx b/frontend/src/components/ui/misc.jsx
new file mode 100644
index 0000000..b7b19d3
--- /dev/null
+++ b/frontend/src/components/ui/misc.jsx
@@ -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 (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ );
+}
+
+/** 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 (
+
+
+ {label}
+ {Icon && }
+
+
{value}
+ {hint &&
{hint}
}
+
+ );
+}
+
+/** Empty / zero-state placeholder for lists and tables. */
+export function EmptyState({ icon: Icon, title, description, action, className }) {
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
+
{title}
+ {description &&
{description}
}
+
+ {action}
+
+ );
+}
diff --git a/frontend/src/components/ui/separator.jsx b/frontend/src/components/ui/separator.jsx
new file mode 100644
index 0000000..a027010
--- /dev/null
+++ b/frontend/src/components/ui/separator.jsx
@@ -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 (
+
+ );
+});
+
+export { Separator };
diff --git a/frontend/src/components/ui/sheet.jsx b/frontend/src/components/ui/sheet.jsx
new file mode 100644
index 0000000..2354084
--- /dev/null
+++ b/frontend/src/components/ui/sheet.jsx
@@ -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 (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ );
+});
+
+function SheetHeader({ className, ...props }) {
+ return
;
+}
+
+function SheetFooter({ className, ...props }) {
+ return
;
+}
+
+const SheetTitle = forwardRef(function SheetTitle({ className, ...props }, ref) {
+ return
;
+});
+
+const SheetDescription = forwardRef(function SheetDescription({ className, ...props }, ref) {
+ return
;
+});
+
+export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
diff --git a/frontend/src/components/ui/skeleton.jsx b/frontend/src/components/ui/skeleton.jsx
new file mode 100644
index 0000000..a9e6c5b
--- /dev/null
+++ b/frontend/src/components/ui/skeleton.jsx
@@ -0,0 +1,7 @@
+import { cn } from '../../lib/utils.js';
+
+function Skeleton({ className, ...props }) {
+ return
;
+}
+
+export { Skeleton };
diff --git a/frontend/src/components/ui/switch.jsx b/frontend/src/components/ui/switch.jsx
new file mode 100644
index 0000000..b81e33b
--- /dev/null
+++ b/frontend/src/components/ui/switch.jsx
@@ -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 (
+
+
+
+ );
+});
+
+export { Switch };
diff --git a/frontend/src/components/ui/table.jsx b/frontend/src/components/ui/table.jsx
new file mode 100644
index 0000000..33255af
--- /dev/null
+++ b/frontend/src/components/ui/table.jsx
@@ -0,0 +1,44 @@
+import { forwardRef } from 'react';
+import { cn } from '../../lib/utils.js';
+
+const Table = forwardRef(function Table({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+const TableHeader = forwardRef(function TableHeader({ className, ...props }, ref) {
+ return
;
+});
+
+const TableBody = forwardRef(function TableBody({ className, ...props }, ref) {
+ return
;
+});
+
+const TableRow = forwardRef(function TableRow({ className, ...props }, ref) {
+ return (
+
|
+ );
+});
+
+const TableHead = forwardRef(function TableHead({ className, ...props }, ref) {
+ return (
+
|
+ );
+});
+
+const TableCell = forwardRef(function TableCell({ className, ...props }, ref) {
+ return
| ;
+});
+
+export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
diff --git a/frontend/src/components/ui/tabs.jsx b/frontend/src/components/ui/tabs.jsx
new file mode 100644
index 0000000..27b6379
--- /dev/null
+++ b/frontend/src/components/ui/tabs.jsx
@@ -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 (
+
+ );
+});
+
+const TabsTrigger = forwardRef(function TabsTrigger({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+const TabsContent = forwardRef(function TabsContent({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/frontend/src/components/ui/theme-toggle.jsx b/frontend/src/components/ui/theme-toggle.jsx
new file mode 100644
index 0000000..e07385b
--- /dev/null
+++ b/frontend/src/components/ui/theme-toggle.jsx
@@ -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 (
+
+
+
+
+ {isDark ? 'Switch to light' : 'Switch to dark'}
+
+ );
+}
diff --git a/frontend/src/components/ui/toast.jsx b/frontend/src/components/ui/toast.jsx
new file mode 100644
index 0000000..a42d234
--- /dev/null
+++ b/frontend/src/components/ui/toast.jsx
@@ -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 (
+
+
+ {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 (
+ !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'
+ )}
+ >
+
+
+ {title && {title}}
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+export function useToast() {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error('useToast must be used within
');
+ return ctx;
+}
diff --git a/frontend/src/components/ui/tooltip.jsx b/frontend/src/components/ui/tooltip.jsx
new file mode 100644
index 0000000..13df580
--- /dev/null
+++ b/frontend/src/components/ui/tooltip.jsx
@@ -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 (
+
+
+
+ );
+});
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 66fb64c..dbf90ae 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -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(
-
-
+
+
+
+
{/* Public landing page */}
} />
@@ -29,8 +32,10 @@ ReactDOM.createRoot(document.getElementById('root')).render(
} />
- } />
-
-
+ } />
+
+
+
+
);