From 6a0c9d0c4eff8310181a748d10f9de623c0d3166 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Thu, 14 May 2026 10:26:53 -0700 Subject: [PATCH] feat: full UI redesign with design system, Nielsen heuristics compliance - Install lucide-react, framer-motion, react-hot-toast, clsx, tailwind-merge - Custom Tailwind config: semantic color tokens, Inter font, shadow scale, border radius scale, custom animations (fadeIn, slideUp, shimmer) - Shared component library: Button, Badge, Card, Input, Select, Textarea, EmptyState, Skeleton, LoadingSpinner - Global CSS with @layer components (.btn, .card, .input, .badge, .skeleton) - Toast notification system via react-hot-toast + showToast utility - ErrorBoundary wrapper for graceful error recovery - Redesigned navigation: sticky, active state indicators, Lucide icons - Dashboard: hero header, today highlighting, scrollable week grid, redesigned meal cards, empty states, skeleton loading - Meal Detail: hero image with gradient overlay, metadata row with icons, Lucide star rating, edit-existing-feedback flow - Pantry: inline add form, search/filter, visual quantity badges, expiry warnings, confirmation dialogs - Shopping List: gradient summary cards, aisle grouping with badges, sale strikethrough pricing, empty state - Login: centered card with icon, Input component, Button component - All old gray/blue utility classes migrated to new surface/primary tokens - TypeScript clean, production build passes --- .agent/plan-ui-redesign.md | 98 ++++ frontend/index.html | 2 + frontend/package-lock.json | 110 ++++- frontend/package.json | 13 +- frontend/src/App.tsx | 103 ++-- frontend/src/components/ErrorBoundary.tsx | 63 +++ frontend/src/components/ui/Badge.tsx | 24 + frontend/src/components/ui/Button.tsx | 53 ++ frontend/src/components/ui/Card.tsx | 37 ++ frontend/src/components/ui/EmptyState.tsx | 29 ++ frontend/src/components/ui/Input.tsx | 31 ++ frontend/src/components/ui/LoadingSpinner.tsx | 23 + frontend/src/components/ui/Select.tsx | 39 ++ frontend/src/components/ui/Skeleton.tsx | 37 ++ frontend/src/components/ui/Textarea.tsx | 31 ++ frontend/src/index.css | 77 ++- frontend/src/main.tsx | 30 ++ frontend/src/pages/Dashboard.tsx | 302 ++++++++---- frontend/src/pages/Login.tsx | 64 ++- frontend/src/pages/MealDetail.tsx | 454 +++++++++++------- frontend/src/pages/Pantry.tsx | 302 ++++++++---- frontend/src/pages/ShoppingList.tsx | 237 ++++++--- frontend/tailwind.config.js | 141 +++++- 23 files changed, 1772 insertions(+), 528 deletions(-) create mode 100644 .agent/plan-ui-redesign.md create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/LoadingSpinner.tsx create mode 100644 frontend/src/components/ui/Select.tsx create mode 100644 frontend/src/components/ui/Skeleton.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx diff --git a/.agent/plan-ui-redesign.md b/.agent/plan-ui-redesign.md new file mode 100644 index 0000000..3dbd18e --- /dev/null +++ b/.agent/plan-ui-redesign.md @@ -0,0 +1,98 @@ +# MealPlanner UI Redesign Plan +## Nielsen's 10 Heuristics → Concrete Changes + +### Classification: `large/high-risk` — full visual redesign + component library + +--- + +## Phase 1: Design System Foundation +- [ ] Custom Tailwind config: color tokens (primary, semantic, surface), font scale, spacing scale, shadow scale, radius scale +- [ ] Install icon library (Lucide React — lightweight, tree-shakeable) +- [ ] Create shared components: Button, Badge, Card, Input, Select, Textarea, Toast, Skeleton, EmptyState, Modal, Tooltip +- [ ] Define animation tokens (Framer Motion — micro-interactions, page transitions) +- [ ] Dark mode foundation (class-based, not implementing yet) + +## Phase 2: Global UX Improvements (Heuristics 1, 3, 5, 6, 7, 9) +- [ ] Toast notification system (React Hot Toast) — system status visibility +- [ ] Loading states: skeleton screens for every async view +- [ ] Error boundaries + graceful error recovery UI +- [ ] Empty states for all lists (no meals, no pantry items, no shopping list) +- [ ] Confirmation dialogs for destructive actions (delete, remove from pantry, never-suggest) +- [ ] Undo capability where possible (remove from pantry → "Undo" toast) +- [ ] Keyboard shortcuts reference (Cmd+K command palette? Maybe too ambitious. Start with basic: / for search, Esc to close modals) + +## Phase 3: Dashboard Redesign (Heuristic 1, 2, 6, 8) +- [ ] Visual hierarchy: week header with clear date range, status summary chips +- [ ] Day columns with clear headers (Mon 5/12, Tue 5/13, etc.) +- [ ] Meal cards: better image handling (aspect ratio, fallback), clearer metadata, hover interactions +- [ ] Quick actions bar: "Generate Week", "Send Vote Email", "View Shopping List" +- [ ] Recent activity / system status panel (last scrape time, next vote date) +- [ ] Responsive: mobile-first card stack, desktop grid + +## Phase 4: Meal Detail Redesign (Heuristic 2, 5, 8) +- [ ] Hero section: full-bleed image, recipe title, key metadata row (time, servings, cost, cuisine tags) +- [ ] Tabbed interface: Overview / Ingredients / Instructions / Feedback +- [ ] Ingredients: checkbox toggle "in pantry", visual quantity display, aisle grouping +- [ ] Instructions: numbered with visual separators, progress indicator +- [ ] Feedback: inline star rating (not a separate card), compact form, saved state indicator +- [ ] Print-friendly layout preserved + +## Phase 5: Pantry Redesign (Heuristic 1, 5, 6, 8) +- [ ] Inline add (not expandable form — always visible, compact) +- [ ] Search + filter existing items +- [ ] Visual quantity badges, expiry warnings (color-coded) +- [ ] Bulk actions (select multiple → remove) +- [ ] Empty state with illustration + CTA + +## Phase 6: Shopping List Redesign (Heuristic 1, 2, 6, 8) +- [ ] Summary header with total cost, savings, item count +- [ ] Aisle sections as collapsible accordions +- [ ] Checkbox strikethrough animation, "in cart" vs "in pantry" distinction +- [ ] Print layout: clean, no UI chrome +- [ ] Empty state when no items + +## Phase 7: Login + Global Nav (Heuristic 1, 4, 8) +- [ ] Centered card with better spacing, icon, and visual focus +- [ ] Nav bar: active state indicator, mobile hamburger, user avatar dropdown +- [ ] Breadcrumbs on sub-pages +- [ ] Footer with version + system status + +## Phase 8: Accessibility + Polish (Heuristic 9, 10) +- [ ] Focus rings on all interactive elements +- [ ] ARIA labels, roles, live regions for toasts +- [ ] Color contrast audit (WCAG AA) +- [ ] Reduced motion support +- [ ] Help tooltips on complex features (vote system, matching algorithm) + +--- + +## Implementation Order +1. Install deps (lucide-react, framer-motion, react-hot-toast) +2. Tailwind config + base design tokens +3. Shared component library +4. Toast + loading infrastructure +5. Dashboard (highest traffic page) +6. Meal Detail +7. Pantry + Shopping List (parallel) +8. Login + Nav +9. Accessibility polish + error boundaries +10. Test responsive on mobile widths + +## New Dependencies +``` +npm install lucide-react framer-motion react-hot-toast +``` + +## Nielsen Mapping Summary +| Heuristic | Where addressed | +|---|---| +| 1. Visibility of status | Skeletons, toasts, loading spinners, progress indicators | +| 2. Match real world | Clear labels, familiar icons, calendar metaphor | +| 3. User control | Confirm dialogs, undo toasts, Escape to close | +| 4. Consistency | Design tokens, shared components, uniform spacing | +| 5. Error prevention | Form validation, disable buttons while loading, confirm destructive actions | +| 6. Recognition > recall | Visual badges, status chips, pantry checkboxes, recent activity | +| 7. Flexibility | Keyboard shortcuts, bulk actions, print layouts | +| 8. Aesthetic + minimal | Clean cards, whitespace, single primary action per view | +| 9. Error recovery | Error boundaries, friendly error messages, retry buttons | +| 10. Help | Empty state CTAs, tooltip hints, inline help text | diff --git a/frontend/index.html b/frontend/index.html index efd461f..9295354 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,8 @@ MealPlanner + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4915828..db3aad3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,9 +10,14 @@ "dependencies": { "@tanstack/react-query": "^5.17.0", "axios": "^1.6.5", + "clsx": "^2.1.1", + "framer-motion": "^12.38.0", + "lucide-react": "^1.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.21.1" + "react-hot-toast": "^2.6.0", + "react-router-dom": "^6.21.1", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@types/react": "^18.2.48", @@ -1756,6 +1761,15 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1844,7 +1858,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2363,6 +2376,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2492,6 +2532,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2860,6 +2909,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2927,6 +2985,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3397,6 +3470,23 @@ "react": "^18.3.1" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3718,6 +3808,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3854,6 +3954,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0875c1..eee92e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,21 +10,26 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "clsx": "^2.1.1", + "framer-motion": "^12.38.0", + "lucide-react": "^1.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^6.21.1", - "@tanstack/react-query": "^5.17.0", - "axios": "^1.6.5" + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", + "eslint": "^8.56.0", "postcss": "^8.4.33", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", - "vite": "^5.0.11", - "eslint": "^8.56.0" + "vite": "^5.0.11" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e756b8..83f6ea8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ -import { BrowserRouter, Routes, Route, Link } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ErrorBoundary } from './components/ErrorBoundary' import Dashboard from './pages/Dashboard' import MealDetail from './pages/MealDetail' import Pantry from './pages/Pantry' @@ -9,52 +10,64 @@ import { mealPlannerApi } from './api' const queryClient = new QueryClient() -function App() { +function Navigation() { + const location = useLocation() + const isActive = (path: string) => location.pathname === path + + const linkClass = (path: string) => + `inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${ + isActive(path) + ? 'text-primary-700 bg-primary-50' + : 'text-surface-600 hover:bg-surface-100' + }` + return ( - - -
- -
- - } /> - } /> - } /> - } /> - } /> - -
+ ) } -export default App +function App() { + return ( + + + +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+
+ ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..463a74a --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,63 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { Button } from './ui/Button'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+

Something went wrong

+

+ We encountered an unexpected error. Try refreshing the page or going back to the dashboard. +

+ {this.state.error && ( +
+ {this.state.error.message} +
+ )} +
+ + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..54d080b --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,24 @@ +import { cn } from '../../lib/utils'; + +interface BadgeProps { + variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral'; + children: React.ReactNode; + className?: string; +} + +export function Badge({ variant = 'neutral', children, className }: BadgeProps) { + const variants = { + primary: 'bg-primary-50 text-primary-700 border border-primary-200', + success: 'bg-success-50 text-success-700 border border-success-200', + warning: 'bg-warning-50 text-warning-700 border border-warning-200', + danger: 'bg-danger-50 text-danger-700 border border-danger-200', + info: 'bg-blue-50 text-blue-700 border border-blue-200', + neutral: 'bg-surface-100 text-surface-600 border border-surface-200', + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..69e2090 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,53 @@ +import { Loader2 } from 'lucide-react'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + loading?: boolean; + icon?: ReactNode; +} + +export function Button({ + variant = 'primary', + size = 'md', + loading = false, + icon, + children, + className = '', + disabled, + ...props +}: ButtonProps) { + const base = 'btn'; + const variants = { + primary: 'btn-primary', + secondary: 'btn-secondary', + ghost: 'btn-ghost', + danger: 'btn-danger', + }; + const sizes = { + sm: 'px-3 py-1.5 text-xs', + md: 'px-4 py-2.5 text-sm', + lg: 'px-6 py-3 text-base', + }; + + return ( + + ); +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..528cc00 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,37 @@ +import { cn } from '../../lib/utils'; + +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export function Card({ children, className }: CardProps) { + return
{children}
; +} + +interface CardHeaderProps { + children: React.ReactNode; + className?: string; +} + +export function CardHeader({ children, className }: CardHeaderProps) { + return
{children}
; +} + +interface CardBodyProps { + children: React.ReactNode; + className?: string; +} + +export function CardBody({ children, className }: CardBodyProps) { + return
{children}
; +} + +interface CardFooterProps { + children: React.ReactNode; + className?: string; +} + +export function CardFooter({ children, className }: CardFooterProps) { + return
{children}
; +} diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..7af6e35 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,29 @@ +import { LucideIcon } from 'lucide-react'; +import { Button } from './Button'; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description: string; + action?: { + label: string; + onClick: () => void; + }; +} + +export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( +
+
+ +
+

{title}

+

{description}

+ {action && ( + + )} +
+ ); +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..3700229 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,31 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { cn } from '../../lib/utils'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + hint?: string; +} + +export const Input = forwardRef( + ({ label, error, hint, className, ...props }, ref) => { + return ( +
+ {label && } + + {error &&

{error}

} + {hint && !error &&

{hint}

} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/frontend/src/components/ui/LoadingSpinner.tsx b/frontend/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..2850db2 --- /dev/null +++ b/frontend/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,23 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '../../lib/utils'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; + label?: string; +} + +export function LoadingSpinner({ size = 'md', className, label }: LoadingSpinnerProps) { + const sizes = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + return ( +
+ + {label && {label}} +
+ ); +} diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 0000000..58a932a --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,39 @@ +import { cn } from '../../lib/utils'; +import type { SelectHTMLAttributes } from 'react'; +import { forwardRef } from 'react'; + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + error?: string; + hint?: string; + options: Array<{ value: string; label: string }>; +} + +export const Select = forwardRef( + ({ label, error, hint, options, className, ...props }, ref) => { + return ( +
+ {label && } + + {error &&

{error}

} + {hint && !error &&

{hint}

} +
+ ); + } +); + +Select.displayName = 'Select'; diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..ae89819 --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,37 @@ +interface SkeletonProps { + className?: string; + count?: number; +} + +export function Skeleton({ className = 'h-4 w-full', count = 1 }: SkeletonProps) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
+ ))} + + ); +} + +export function SkeletonCard() { + return ( +
+ + +
+ + +
+
+ ); +} + +export function SkeletonText({ lines = 3 }: { lines?: number }) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/ui/Textarea.tsx b/frontend/src/components/ui/Textarea.tsx new file mode 100644 index 0000000..b4ce7d4 --- /dev/null +++ b/frontend/src/components/ui/Textarea.tsx @@ -0,0 +1,31 @@ +import { forwardRef, type TextareaHTMLAttributes } from 'react'; +import { cn } from '../../lib/utils'; + +interface TextareaProps extends TextareaHTMLAttributes { + label?: string; + error?: string; + hint?: string; +} + +export const Textarea = forwardRef( + ({ label, error, hint, className, ...props }, ref) => { + return ( +
+ {label && } +