From d60d834f38b078becb28e12a6d6348c19c82e9a7 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 23 Sep 2025 04:08:45 +0000 Subject: [PATCH] Create a frontend wireframe. (#7) Sets up API methods and types. Sets up a colorscheme. Sets up a homepage. Removes tailwind in favor of mui for now. Reviewed-on: https://git.tiramisu.one/drew/captains-log/pulls/7 Co-authored-by: Drew Galbraith Co-committed-by: Drew Galbraith --- CLAUDE.md | 39 +- PRD.md | 2 +- frontend/README.md | 87 -- frontend/app/app.css | 59 +- frontend/app/components/ErrorFallback.tsx | 64 + frontend/app/components/Layout.tsx | 216 ++++ frontend/app/components/LoadingSpinner.tsx | 53 + frontend/app/hooks/useApi.test.ts | 359 ++++++ frontend/app/hooks/useApi.ts | 227 ++++ frontend/app/hooks/useTask.test.ts | 211 ++++ frontend/app/hooks/useTask.ts | 114 ++ frontend/app/hooks/useTasks.test.ts | 318 +++++ frontend/app/hooks/useTasks.ts | 161 +++ frontend/app/root.tsx | 36 +- frontend/app/routes/home.test.tsx | 10 +- frontend/app/routes/home.tsx | 39 +- frontend/app/services/api.test.ts | 267 ++++ frontend/app/services/api.ts | 113 ++ frontend/app/theme.ts | 178 +++ frontend/app/types/task.ts | 35 + frontend/package-lock.json | 1335 ++++++++++---------- frontend/package.json | 6 +- frontend/tailwind.config.js | 76 -- frontend/vite.config.ts | 3 +- justfile | 2 + plan/01_CORE_MVP/frontend.md | 77 +- plan/01_CORE_MVP/plan.md | 4 +- 27 files changed, 3114 insertions(+), 977 deletions(-) delete mode 100644 frontend/README.md create mode 100644 frontend/app/components/ErrorFallback.tsx create mode 100644 frontend/app/components/Layout.tsx create mode 100644 frontend/app/components/LoadingSpinner.tsx create mode 100644 frontend/app/hooks/useApi.test.ts create mode 100644 frontend/app/hooks/useApi.ts create mode 100644 frontend/app/hooks/useTask.test.ts create mode 100644 frontend/app/hooks/useTask.ts create mode 100644 frontend/app/hooks/useTasks.test.ts create mode 100644 frontend/app/hooks/useTasks.ts create mode 100644 frontend/app/services/api.test.ts create mode 100644 frontend/app/services/api.ts create mode 100644 frontend/app/theme.ts create mode 100644 frontend/app/types/task.ts delete mode 100644 frontend/tailwind.config.js diff --git a/CLAUDE.md b/CLAUDE.md index 5686f30..a84b622 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,11 +50,11 @@ backend/ └── migrations/ # SQLx database migrations ``` -### Frontend: Vite + React + Tailwind CSS +### Frontend: Vite + React - **Build Tool**: Vite for fast development and optimized builds - **Framework**: React with functional components and hooks - **Routing**: React Router for client-side navigation -- **Styling**: Tailwind CSS for rapid UI development +- **Styling**: CSS for styling - **State Management**: React state with localStorage persistence - **PWA Features**: Service worker for offline functionality - **Mobile Optimization**: Touch gestures, responsive design @@ -74,38 +74,11 @@ tasks (id, title, description, status, created_at, updated_at, completed_at) ## Development Commands -### Testing -```bash -# Backend tests -just test-unit # Unit tests (cargo test) -just test-coverage # Coverage report (tarpaulin HTML) -just test-integration # API tests (Hurl) +**Primary command**: Use `just` for all development tasks. Run `just --list` to see all available commands. -# Individual commands -cargo test # Direct unit test execution -hurl --test tests/api/*.hurl # Direct API test execution - -# Frontend tests (when implemented) -npm test # Unit tests -npm run test:e2e # End-to-end tests -npm run test:coverage # Coverage report -``` - -### Development Server -```bash -# Backend (Rust server) -just dev # Run backend server (cargo run) - -# Other backend commands -just build # Build project -just migrate # Run database migrations -just reset-db # Reset database -just fmt # Format code -just lint # Run clippy - -# Frontend (when implemented) -npm run dev # Vite dev server -``` +**Key commands**: +- `just check` - Validate all changes (primary validation command) +- `just fmt` - Format code (resolve formatting errors) ## Current Phase: Core MVP Backend ✅ diff --git a/PRD.md b/PRD.md index c8794ef..064b1ee 100644 --- a/PRD.md +++ b/PRD.md @@ -89,7 +89,7 @@ - **Build Tool**: Vite for fast development and optimized builds - **Framework**: React with functional components and hooks - **Routing**: React Router for client-side navigation -- **Styling**: Tailwind CSS for rapid UI development +- **Styling**: CSS for styling - **State Management**: React state with localStorage persistence - **PWA Features**: Service worker for offline functionality - **Mobile Optimization**: Touch gestures, responsive design diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 5c4780a..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Welcome to React Router! - -A modern, production-ready template for building full-stack React applications using React Router. - -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) - -## Features - -- 🚀 Server-side rendering -- ⚡️ Hot Module Replacement (HMR) -- 📦 Asset bundling and optimization -- 🔄 Data loading and mutations -- 🔒 TypeScript by default -- 🎉 TailwindCSS for styling -- 📖 [React Router docs](https://reactrouter.com/) - -## Getting Started - -### Installation - -Install the dependencies: - -```bash -npm install -``` - -### Development - -Start the development server with HMR: - -```bash -npm run dev -``` - -Your application will be available at `http://localhost:5173`. - -## Building for Production - -Create a production build: - -```bash -npm run build -``` - -## Deployment - -### Docker Deployment - -To build and run using Docker: - -```bash -docker build -t my-app . - -# Run the container -docker run -p 3000:3000 my-app -``` - -The containerized application can be deployed to any platform that supports Docker, including: - -- AWS ECS -- Google Cloud Run -- Azure Container Apps -- Digital Ocean App Platform -- Fly.io -- Railway - -### DIY Deployment - -If you're familiar with deploying Node applications, the built-in app server is production-ready. - -Make sure to deploy the output of `npm run build` - -``` -├── package.json -├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) -├── build/ -│ ├── client/ # Static assets -│ └── server/ # Server-side code -``` - -## Styling - -This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. - ---- - -Built with ❤️ using React Router. diff --git a/frontend/app/app.css b/frontend/app/app.css index ba71dcb..e4d1dd9 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -1,59 +1,8 @@ -@import 'tailwindcss'; - -@theme { - --font-sans: - 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - - /* Captain's Log Design Tokens */ - --color-primary-50: #eff6ff; - --color-primary-100: #dbeafe; - --color-primary-500: #3b82f6; - --color-primary-950: #172554; - - --color-task-todo: #3b82f6; - --color-task-done: #22c55e; - --color-task-backlog: #6b7280; -} +/* Captain's Log - Global Styles */ +/* Base font family is handled by Material-UI theme */ html, body { - @apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } -} - -/* Captain's Log Component Styles */ -.task-card { - @apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200; -} - -.task-card:hover { - @apply shadow-md border-gray-300 dark:border-gray-600; -} - -.quick-capture { - @apply bg-white dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-4 transition-colors duration-200; -} - -.quick-capture:focus-within { - @apply border-blue-500 bg-blue-50 dark:bg-blue-950; -} - -.status-badge { - @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; -} - -.status-todo { - @apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200; -} - -.status-done { - @apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200; -} - -.status-backlog { - @apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200; + margin: 0; + padding: 0; } diff --git a/frontend/app/components/ErrorFallback.tsx b/frontend/app/components/ErrorFallback.tsx new file mode 100644 index 0000000..f76a546 --- /dev/null +++ b/frontend/app/components/ErrorFallback.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { + Alert, + AlertTitle, + Box, + Button, + Container, + Typography, + Paper, +} from '@mui/material' +import { + Refresh as RefreshIcon, + BugReport as BugReportIcon, +} from '@mui/icons-material' + +interface ErrorFallbackProps { + error: Error + resetError: () => void +} + +export default function ErrorFallback({ + error, + resetError, +}: ErrorFallbackProps) { + return ( + + + + + + Something went wrong + + + An unexpected error occurred. Please try refreshing the page or + contact support if the problem persists. + + + + + Error Details + {error.message} + + + + + + + + + ) +} diff --git a/frontend/app/components/Layout.tsx b/frontend/app/components/Layout.tsx new file mode 100644 index 0000000..169e9f5 --- /dev/null +++ b/frontend/app/components/Layout.tsx @@ -0,0 +1,216 @@ +import React, { useState } from 'react' +import { + AppBar, + Box, + CssBaseline, + Drawer, + IconButton, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Typography, + useTheme, + Fab, + LinearProgress, +} from '@mui/material' +import { + Menu as MenuIcon, + Dashboard as DashboardIcon, + Add as AddIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' + +const drawerWidth = 240 + +interface LayoutProps { + children: React.ReactNode + loading?: boolean +} + +export default function Layout({ children, loading = false }: LayoutProps) { + const theme = useTheme() + const [mobileOpen, setMobileOpen] = useState(false) + + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen) + } + + const menuItems = [ + { + text: 'Tasks', + icon: , + path: '/', + }, + { + text: 'Settings', + icon: , + path: '/settings', + }, + ] + + const drawer = ( +
+ + {menuItems.map(item => ( + + + + {item.icon} + + + + + ))} + +
+ ) + + return ( + + + + {/* Loading indicator */} + {loading && ( + + )} + + {/* App Bar */} + + + + + + + + ⚓ Captain's Log + + + + + {/* Drawer */} + + {/* Mobile drawer */} + + {drawer} + + + {/* Desktop drawer */} + + {drawer} + + + + {/* Main content */} + + + {children} + + + {/* Quick capture FAB */} + + + + + ) +} diff --git a/frontend/app/components/LoadingSpinner.tsx b/frontend/app/components/LoadingSpinner.tsx new file mode 100644 index 0000000..24955c3 --- /dev/null +++ b/frontend/app/components/LoadingSpinner.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { Box, CircularProgress, Typography, Skeleton } from '@mui/material' + +interface LoadingSpinnerProps { + size?: number + message?: string + variant?: 'spinner' | 'skeleton' +} + +export default function LoadingSpinner({ + size = 40, + message = 'Loading...', + variant = 'spinner', +}: LoadingSpinnerProps) { + if (variant === 'skeleton') { + return ( + + + + + + + ) + } + + return ( + + + + {message} + + + ) +} diff --git a/frontend/app/hooks/useApi.test.ts b/frontend/app/hooks/useApi.test.ts new file mode 100644 index 0000000..e30aacf --- /dev/null +++ b/frontend/app/hooks/useApi.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useApi, useApiForm, useApiCache } from './useApi' + +// Mock API function for testing +const mockApiFunction = vi.fn() +const mockFormSubmitFunction = vi.fn() +const mockCacheFunction = vi.fn() + +describe('useApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should initialize with default state', () => { + const { result } = renderHook(() => useApi(mockApiFunction)) + + expect(result.current.data).toBeNull() + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should execute API function successfully', async () => { + const mockData = { id: 1, name: 'Test' } + mockApiFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => useApi(mockApiFunction)) + + let executeResult: unknown + + await act(async () => { + executeResult = await result.current.execute('test-arg') + }) + + expect(result.current.data).toEqual(mockData) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + expect(executeResult).toEqual(mockData) + expect(mockApiFunction).toHaveBeenCalledWith('test-arg') + }) + + it('should handle API function errors', async () => { + const errorMessage = 'API Error' + mockApiFunction.mockRejectedValueOnce({ message: errorMessage }) + + const { result } = renderHook(() => useApi(mockApiFunction)) + + let executeResult: unknown + + await act(async () => { + executeResult = await result.current.execute() + }) + + expect(result.current.data).toBeNull() + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(errorMessage) + expect(executeResult).toBeNull() + }) + + it('should call onSuccess callback', async () => { + const mockData = { id: 1 } + const onSuccess = vi.fn() + mockApiFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => useApi(mockApiFunction, { onSuccess })) + + await act(async () => { + await result.current.execute() + }) + + expect(onSuccess).toHaveBeenCalledWith(mockData) + }) + + it('should call onError callback', async () => { + const errorMessage = 'Test Error' + const onError = vi.fn() + mockApiFunction.mockRejectedValueOnce({ message: errorMessage }) + + const { result } = renderHook(() => useApi(mockApiFunction, { onError })) + + await act(async () => { + await result.current.execute() + }) + + expect(onError).toHaveBeenCalledWith(errorMessage) + }) + + it('should execute immediately when immediate option is true', async () => { + const mockData = { id: 1 } + mockApiFunction.mockResolvedValueOnce(mockData) + + renderHook(() => useApi(mockApiFunction, { immediate: true })) + + await waitFor(() => { + expect(mockApiFunction).toHaveBeenCalled() + }) + }) + + it('should reset state', async () => { + const mockData = { id: 1 } + mockApiFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => useApi(mockApiFunction)) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.data).toEqual(mockData) + + act(() => { + result.current.reset() + }) + + expect(result.current.data).toBeNull() + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should clear error', async () => { + mockApiFunction.mockRejectedValueOnce({ message: 'Test Error' }) + + const { result } = renderHook(() => useApi(mockApiFunction)) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.error).toBeTruthy() + + act(() => { + result.current.clearError() + }) + + expect(result.current.error).toBeNull() + }) + + it('should cancel previous requests', async () => { + let resolveFirst: (value: unknown) => void + let resolveSecond: (value: unknown) => void + + const firstPromise = new Promise(resolve => { + resolveFirst = resolve + }) + const secondPromise = new Promise(resolve => { + resolveSecond = resolve + }) + + mockApiFunction + .mockReturnValueOnce(firstPromise) + .mockReturnValueOnce(secondPromise) + + const { result } = renderHook(() => useApi(mockApiFunction)) + + // Start first request + act(() => { + result.current.execute('first') + }) + + // Start second request before first completes + act(() => { + result.current.execute('second') + }) + + // Resolve first request (should be cancelled) + await act(async () => { + resolveFirst({ data: 'first' }) + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + // Resolve second request + await act(async () => { + resolveSecond({ data: 'second' }) + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(result.current.data).toEqual({ data: 'second' }) + }) +}) + +describe('useApiForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should handle form submission', async () => { + const formData = { name: 'Test' } + const responseData = { id: 1, name: 'Test' } + mockFormSubmitFunction.mockResolvedValueOnce(responseData) + + const { result } = renderHook(() => useApiForm(mockFormSubmitFunction)) + + let submitResult: unknown + + await act(async () => { + submitResult = await result.current.handleSubmit(formData) + }) + + expect(submitResult).toEqual(responseData) + expect(mockFormSubmitFunction).toHaveBeenCalledWith(formData) + expect(result.current.data).toEqual(responseData) + }) + + it('should reset on success when option is enabled', async () => { + const formData = { name: 'Test' } + const responseData = { id: 1, name: 'Test' } + mockFormSubmitFunction.mockResolvedValueOnce(responseData) + + const { result } = renderHook(() => + useApiForm(mockFormSubmitFunction, { resetOnSuccess: true }) + ) + + await act(async () => { + await result.current.handleSubmit(formData) + }) + + expect(result.current.data).toBeNull() + }) +}) + +describe('useApiCache', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch and cache data', async () => { + const mockData = { id: 1, name: 'Test' } + mockCacheFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => + useApiCache('test-key', mockCacheFunction) + ) + + let fetchResult: unknown + + await act(async () => { + fetchResult = await result.current.fetchData() + }) + + expect(fetchResult).toEqual(mockData) + expect(result.current.data).toEqual(mockData) + expect(mockCacheFunction).toHaveBeenCalledTimes(1) + }) + + it('should return cached data when fresh', async () => { + const mockData = { id: 1, name: 'Test' } + mockCacheFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => + useApiCache('test-key', mockCacheFunction, { staleTime: 60000 }) + ) + + // First fetch + await act(async () => { + await result.current.fetchData() + }) + + // Second fetch should return cached data + let secondResult: unknown + + await act(async () => { + secondResult = await result.current.fetchData() + }) + + expect(secondResult).toEqual(mockData) + expect(mockCacheFunction).toHaveBeenCalledTimes(1) // Should not call API again + }) + + it('should fetch fresh data when stale', async () => { + const mockData1 = { id: 1, name: 'Test 1' } + const mockData2 = { id: 2, name: 'Test 2' } + + mockCacheFunction + .mockResolvedValueOnce(mockData1) + .mockResolvedValueOnce(mockData2) + + const { result } = renderHook(() => + useApiCache('test-key', mockCacheFunction, { staleTime: 1000 }) + ) + + // First fetch + await act(async () => { + await result.current.fetchData() + }) + + expect(result.current.data).toEqual(mockData1) + + // Advance time past stale time + act(() => { + vi.advanceTimersByTime(2000) + }) + + // Second fetch should get fresh data + await act(async () => { + await result.current.fetchData() + }) + + expect(result.current.data).toEqual(mockData2) + expect(mockCacheFunction).toHaveBeenCalledTimes(2) + }) + + it('should clear cache', async () => { + const mockData = { id: 1, name: 'Test' } + mockCacheFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => + useApiCache('test-key', mockCacheFunction) + ) + + await act(async () => { + await result.current.fetchData() + }) + + expect(result.current.data).toEqual(mockData) + + await act(async () => { + result.current.clearCache() + }) + + expect(result.current.data).toBeNull() + }) + + it('should indicate stale status', async () => { + const mockData = { id: 1, name: 'Test' } + mockCacheFunction.mockResolvedValueOnce(mockData) + + const { result } = renderHook(() => + useApiCache('test-key', mockCacheFunction, { staleTime: 1000 }) + ) + + // Initially stale (no data) + expect(result.current.isStale).toBe(true) + + // Fetch data + await act(async () => { + await result.current.fetchData() + }) + + // Should be fresh + expect(result.current.isStale).toBe(false) + + // Advance time past stale time + act(() => { + vi.advanceTimersByTime(2000) + }) + + // Should be stale again + expect(result.current.isStale).toBe(true) + }) +}) diff --git a/frontend/app/hooks/useApi.ts b/frontend/app/hooks/useApi.ts new file mode 100644 index 0000000..bdfacea --- /dev/null +++ b/frontend/app/hooks/useApi.ts @@ -0,0 +1,227 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import type { ApiError } from '~/types/task' + +interface UseApiState { + data: T | null + loading: boolean + error: string | null +} + +interface UseApiActions { + execute: (...args: unknown[]) => Promise + reset: () => void + clearError: () => void +} + +interface UseApiOptions { + immediate?: boolean + onSuccess?: (data: unknown) => void + onError?: (error: string) => void +} + +export function useApi( + apiFunction: (...args: unknown[]) => Promise, + options: UseApiOptions = {} +): UseApiState & UseApiActions { + const { immediate = false, onSuccess, onError } = options + + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }) + + const mountedRef = useRef(true) + const abortControllerRef = useRef(null) + + useEffect(() => { + return () => { + mountedRef.current = false + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + const reset = useCallback(() => { + setState({ + data: null, + loading: false, + error: null, + }) + }, []) + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })) + }, []) + + const execute = useCallback( + async (...args: unknown[]): Promise => { + // Cancel previous request if still pending + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + abortControllerRef.current = new AbortController() + + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + const result = await apiFunction(...args) + + if (!mountedRef.current) return null + + setState(prev => ({ + ...prev, + data: result, + loading: false, + })) + + if (onSuccess) { + onSuccess(result) + } + + return result + } catch (error) { + if (!mountedRef.current) return null + + const apiError = error as ApiError + const errorMessage = apiError.message || 'An unknown error occurred' + + setState(prev => ({ + ...prev, + loading: false, + error: errorMessage, + })) + + if (onError) { + onError(errorMessage) + } + + return null + } finally { + abortControllerRef.current = null + } + }, + [apiFunction, onSuccess, onError] + ) + + // Execute immediately if requested + useEffect(() => { + if (immediate) { + execute() + } + }, [immediate, execute]) + + return { + ...state, + execute, + reset, + clearError, + } +} + +// Utility hook for handling form submissions +export function useApiForm( + submitFunction: (data: unknown) => Promise, + options: UseApiOptions & { resetOnSuccess?: boolean } = {} +) { + const { resetOnSuccess = false, ...apiOptions } = options + + const api = useApi(submitFunction, apiOptions) + + const handleSubmit = useCallback( + async (data: unknown) => { + const result = await api.execute(data) + + if (result && resetOnSuccess) { + api.reset() + } + + return result + }, + [api, resetOnSuccess] + ) + + return { + ...api, + handleSubmit, + } +} + +// Utility hook for data caching and synchronization +export function useApiCache( + key: string, + fetchFunction: () => Promise, + options: { cacheTime?: number; staleTime?: number } = {} +) { + const { cacheTime = 5 * 60 * 1000, staleTime = 30 * 1000 } = options // 5min cache, 30s stale + + const [cacheData, setCacheData] = useState<{ + data: T | null + timestamp: number + } | null>(null) + + const api = useApi(fetchFunction) + + const getCachedData = useCallback((): T | null => { + if (!cacheData) return null + + const now = Date.now() + const age = now - cacheData.timestamp + + if (age > cacheTime) { + setCacheData(null) + return null + } + + return cacheData.data + }, [cacheData, cacheTime]) + + const isStale = useCallback((): boolean => { + if (!cacheData) return true + + const now = Date.now() + const age = now - cacheData.timestamp + + return age > staleTime + }, [cacheData, staleTime]) + + const fetchData = useCallback( + async (force = false): Promise => { + // Return cached data if fresh and not forced + if (!force && !isStale()) { + const cached = getCachedData() + if (cached) return cached + } + + const result = await api.execute() + + if (result) { + setCacheData({ + data: result, + timestamp: Date.now(), + }) + } + + return result + }, + [api, isStale, getCachedData] + ) + + const clearCache = useCallback(() => { + setCacheData(null) + api.reset() + }, [api]) + + return { + data: api.data || getCachedData(), + loading: api.loading, + error: api.error, + fetchData, + clearCache, + get isStale() { + return isStale() + }, + } +} diff --git a/frontend/app/hooks/useTask.test.ts b/frontend/app/hooks/useTask.test.ts new file mode 100644 index 0000000..4004e4d --- /dev/null +++ b/frontend/app/hooks/useTask.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useTask } from './useTask' +import type { Task, UpdateTaskRequest } from '~/types/task' +import { TaskStatus } from '~/types/task' +import { apiClient } from '~/services/api' + +// Mock the API client +vi.mock('~/services/api', () => ({ + apiClient: { + getTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + }, +})) + +const mockApiClient = apiClient as unknown as { + getTask: ReturnType + updateTask: ReturnType + deleteTask: ReturnType +} + +// Sample task data +const mockTask: Task = { + id: '550e8400-e29b-41d4-a716-446655440000', + title: 'Test Task', + description: 'Test Description', + status: TaskStatus.Todo, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + completed_at: null, +} + +describe('useTask', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should initialize with default state', () => { + const { result } = renderHook(() => useTask()) + + expect(result.current.task).toBeNull() + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + describe('getTask', () => { + it('should fetch task successfully', async () => { + mockApiClient.getTask.mockResolvedValueOnce(mockTask) + + const { result } = renderHook(() => useTask()) + + act(() => { + result.current.getTask(mockTask.id) + }) + + expect(result.current.loading).toBe(true) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.task).toEqual(mockTask) + expect(result.current.error).toBeNull() + expect(mockApiClient.getTask).toHaveBeenCalledWith(mockTask.id) + }) + + it('should handle fetch errors', async () => { + const errorMessage = 'Task not found' + mockApiClient.getTask.mockRejectedValueOnce({ message: errorMessage }) + + const { result } = renderHook(() => useTask()) + + act(() => { + result.current.getTask(mockTask.id) + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.task).toBeNull() + expect(result.current.error).toBe(errorMessage) + }) + }) + + describe('updateTask', () => { + it('should update task with optimistic update', async () => { + const updateData: UpdateTaskRequest = { + title: 'Updated Task', + status: TaskStatus.Done, + } + const updatedTask = { ...mockTask, ...updateData } + + mockApiClient.getTask.mockResolvedValueOnce(mockTask) + mockApiClient.updateTask.mockResolvedValueOnce(updatedTask) + + const { result } = renderHook(() => useTask()) + + // Set initial task + await act(async () => { + await result.current.getTask(mockTask.id) + }) + + let updateResult: Task | null = null + + await act(async () => { + updateResult = await result.current.updateTask(mockTask.id, updateData) + }) + + expect(updateResult).toEqual(updatedTask) + expect(mockApiClient.updateTask).toHaveBeenCalledWith( + mockTask.id, + updateData + ) + }) + + it('should handle update errors and revert optimistic update', async () => { + const updateData: UpdateTaskRequest = { status: TaskStatus.Done } + const errorMessage = 'Update failed' + + // Setup initial task + mockApiClient.getTask.mockResolvedValueOnce(mockTask) + + const { result } = renderHook(() => useTask()) + + // Set initial task state + await act(async () => { + await result.current.getTask(mockTask.id) + }) + + expect(result.current.task).toEqual(mockTask) + + // Mock update failure and revert call + mockApiClient.updateTask.mockRejectedValueOnce({ message: errorMessage }) + mockApiClient.getTask.mockResolvedValueOnce(mockTask) + + let updateResult: Task | null = null + + await act(async () => { + updateResult = await result.current.updateTask(mockTask.id, updateData) + }) + + expect(updateResult).toBeNull() + expect(result.current.error).toBe(errorMessage) + expect(result.current.loading).toBe(false) + }) + }) + + describe('deleteTask', () => { + it('should delete task successfully', async () => { + mockApiClient.deleteTask.mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => useTask()) + + // Set initial task + await act(async () => { + mockApiClient.getTask.mockResolvedValueOnce(mockTask) + await result.current.getTask(mockTask.id) + }) + + await act(async () => { + await result.current.deleteTask(mockTask.id) + }) + + expect(result.current.task).toBeNull() + expect(result.current.error).toBeNull() + expect(mockApiClient.deleteTask).toHaveBeenCalledWith(mockTask.id) + }) + + it('should handle delete errors', async () => { + const errorMessage = 'Delete failed' + mockApiClient.deleteTask.mockRejectedValueOnce({ message: errorMessage }) + + const { result } = renderHook(() => useTask()) + + await act(async () => { + await result.current.deleteTask(mockTask.id) + }) + + expect(result.current.error).toBe(errorMessage) + expect(result.current.loading).toBe(false) + }) + }) + + describe('clearError', () => { + it('should clear error state', async () => { + mockApiClient.getTask.mockRejectedValueOnce({ message: 'Test error' }) + + const { result } = renderHook(() => useTask()) + + // Trigger an error + await act(async () => { + await result.current.getTask(mockTask.id) + }) + + expect(result.current.error).toBeTruthy() + + // Clear the error + act(() => { + result.current.clearError() + }) + + expect(result.current.error).toBeNull() + }) + }) +}) diff --git a/frontend/app/hooks/useTask.ts b/frontend/app/hooks/useTask.ts new file mode 100644 index 0000000..23a17b4 --- /dev/null +++ b/frontend/app/hooks/useTask.ts @@ -0,0 +1,114 @@ +import { useState, useCallback } from 'react' +import type { Task, UpdateTaskRequest, ApiError } from '~/types/task' +import { apiClient } from '~/services/api' + +interface UseTaskState { + task: Task | null + loading: boolean + error: string | null +} + +interface UseTaskActions { + getTask: (id: string) => Promise + updateTask: (id: string, data: UpdateTaskRequest) => Promise + deleteTask: (id: string) => Promise + clearError: () => void +} + +export function useTask(): UseTaskState & UseTaskActions { + const [state, setState] = useState({ + task: null, + loading: false, + error: null, + }) + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })) + }, []) + + const getTask = useCallback(async (id: string) => { + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + const task = await apiClient.getTask(id) + setState(prev => ({ ...prev, task, loading: false })) + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + loading: false, + error: apiError.message, + })) + } + }, []) + + const updateTask = useCallback( + async (id: string, data: UpdateTaskRequest): Promise => { + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + // Optimistic update + if (state.task && state.task.id === id) { + const optimisticTask: Task = { + ...state.task, + ...data, + updated_at: new Date().toISOString(), + } + setState(prev => ({ ...prev, task: optimisticTask })) + } + + const updatedTask = await apiClient.updateTask(id, data) + setState(prev => ({ ...prev, task: updatedTask, loading: false })) + return updatedTask + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + loading: false, + error: apiError.message, + })) + + // Revert optimistic update on error + if (state.task && state.task.id === id) { + try { + const originalTask = await apiClient.getTask(id) + setState(prev => ({ ...prev, task: originalTask })) + } catch { + // If we can't revert, just keep the optimistic state + } + } + + return null + } + }, + [state.task] + ) + + const deleteTask = useCallback(async (id: string) => { + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + await apiClient.deleteTask(id) + setState(prev => ({ + ...prev, + task: prev.task?.id === id ? null : prev.task, + loading: false, + })) + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + loading: false, + error: apiError.message, + })) + } + }, []) + + return { + ...state, + getTask, + updateTask, + deleteTask, + clearError, + } +} diff --git a/frontend/app/hooks/useTasks.test.ts b/frontend/app/hooks/useTasks.test.ts new file mode 100644 index 0000000..7b68dfe --- /dev/null +++ b/frontend/app/hooks/useTasks.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useTasks } from './useTasks' +import type { Task, CreateTaskRequest } from '~/types/task' +import { TaskStatus } from '~/types/task' +import { apiClient } from '~/services/api' + +// Mock the API client +vi.mock('~/services/api', () => ({ + apiClient: { + listTasks: vi.fn(), + createTask: vi.fn(), + }, +})) + +const mockApiClient = apiClient as unknown as { + listTasks: ReturnType + createTask: ReturnType +} + +// Sample task data +const mockTasks: Task[] = [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + title: 'Test Task 1', + description: 'Test Description 1', + status: TaskStatus.Todo, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + completed_at: null, + }, + { + id: '550e8400-e29b-41d4-a716-446655440001', + title: 'Test Task 2', + description: 'Test Description 2', + status: TaskStatus.Done, + created_at: '2023-01-01T01:00:00Z', + updated_at: '2023-01-01T01:00:00Z', + completed_at: '2023-01-01T02:00:00Z', + }, + { + id: '550e8400-e29b-41d4-a716-446655440002', + title: 'Test Task 3', + description: null, + status: TaskStatus.Backlog, + created_at: '2023-01-01T02:00:00Z', + updated_at: '2023-01-01T02:00:00Z', + completed_at: null, + }, +] + +describe('useTasks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should initialize with default state', () => { + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + expect(result.current.tasks).toEqual([]) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.lastFetch).toBeNull() + }) + + it('should auto-fetch tasks on mount by default', async () => { + mockApiClient.listTasks.mockResolvedValueOnce(mockTasks) + + renderHook(() => useTasks()) + + await waitFor(() => { + expect(mockApiClient.listTasks).toHaveBeenCalledTimes(1) + }) + }) + + it('should not auto-fetch when disabled', () => { + renderHook(() => useTasks({ autoFetch: false })) + + expect(mockApiClient.listTasks).not.toHaveBeenCalled() + }) + + describe('fetchTasks', () => { + it('should fetch tasks successfully', async () => { + mockApiClient.listTasks.mockResolvedValueOnce(mockTasks) + + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + await act(async () => { + await result.current.fetchTasks() + }) + + expect(result.current.tasks).toEqual(mockTasks) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.lastFetch).toBeInstanceOf(Date) + }) + + it('should handle fetch errors', async () => { + const errorMessage = 'Fetch failed' + mockApiClient.listTasks.mockRejectedValueOnce({ message: errorMessage }) + + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + await act(async () => { + await result.current.fetchTasks() + }) + + expect(result.current.tasks).toEqual([]) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(errorMessage) + }) + }) + + describe('createTask', () => { + it('should create task successfully', async () => { + const newTaskData: CreateTaskRequest = { + title: 'New Task', + description: 'New Description', + } + const newTask: Task = { + id: 'new-task-id', + title: newTaskData.title, + description: newTaskData.description || null, + status: TaskStatus.Todo, + created_at: '2023-01-01T03:00:00Z', + updated_at: '2023-01-01T03:00:00Z', + completed_at: null, + } + + mockApiClient.createTask.mockResolvedValueOnce(newTask) + + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + // Set initial tasks + await act(async () => { + mockApiClient.listTasks.mockResolvedValueOnce(mockTasks) + await result.current.fetchTasks() + }) + + let createResult: Task | null = null + + await act(async () => { + createResult = await result.current.createTask(newTaskData) + }) + + expect(createResult).toEqual(newTask) + expect(result.current.tasks[0]).toEqual(newTask) // Should be first in list + expect(result.current.tasks).toHaveLength(mockTasks.length + 1) + expect(mockApiClient.createTask).toHaveBeenCalledWith(newTaskData) + }) + + it('should handle create errors', async () => { + const newTaskData: CreateTaskRequest = { title: '' } + const errorMessage = 'Title must not be empty' + + mockApiClient.createTask.mockRejectedValueOnce({ message: errorMessage }) + + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + let createResult: Task | null = null + + await act(async () => { + createResult = await result.current.createTask(newTaskData) + }) + + expect(createResult).toBeNull() + expect(result.current.error).toBe(errorMessage) + expect(result.current.loading).toBe(false) + }) + }) + + describe('refreshTasks', () => { + it('should refresh tasks without loading state when tasks exist', async () => { + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + // Set initial tasks + await act(async () => { + mockApiClient.listTasks.mockResolvedValueOnce(mockTasks) + await result.current.fetchTasks() + }) + + // Refresh with updated tasks + const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }] + mockApiClient.listTasks.mockResolvedValueOnce(updatedTasks) + + await act(async () => { + await result.current.refreshTasks() + }) + + expect(result.current.tasks).toEqual(updatedTasks) + }) + + it('should show loading state when no tasks exist', async () => { + mockApiClient.listTasks.mockResolvedValueOnce(mockTasks) + + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + act(() => { + result.current.refreshTasks() + }) + + expect(result.current.loading).toBe(true) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + }) + }) + + describe('utility functions', () => { + beforeEach(async () => { + mockApiClient.listTasks.mockResolvedValueOnce(mockTasks) + }) + + it('should get task by ID', async () => { + const { result } = renderHook(() => useTasks()) + + await waitFor(() => { + expect(result.current.tasks).toHaveLength(mockTasks.length) + }) + + const foundTask = result.current.getTaskById(mockTasks[0].id) + expect(foundTask).toEqual(mockTasks[0]) + + const notFoundTask = result.current.getTaskById('nonexistent-id') + expect(notFoundTask).toBeUndefined() + }) + + it('should filter tasks by status', async () => { + const { result } = renderHook(() => useTasks()) + + await waitFor(() => { + expect(result.current.tasks).toHaveLength(mockTasks.length) + }) + + const todoTasks = result.current.filterTasksByStatus(TaskStatus.Todo) + expect(todoTasks).toHaveLength(1) + expect(todoTasks[0].status).toBe(TaskStatus.Todo) + + const doneTasks = result.current.filterTasksByStatus(TaskStatus.Done) + expect(doneTasks).toHaveLength(1) + expect(doneTasks[0].status).toBe(TaskStatus.Done) + + const backlogTasks = result.current.filterTasksByStatus( + TaskStatus.Backlog + ) + expect(backlogTasks).toHaveLength(1) + expect(backlogTasks[0].status).toBe(TaskStatus.Backlog) + }) + }) + + describe('clearError', () => { + it('should clear error state', async () => { + mockApiClient.listTasks.mockRejectedValueOnce({ message: 'Test error' }) + + const { result } = renderHook(() => useTasks({ autoFetch: false })) + + // Trigger an error + await act(async () => { + await result.current.fetchTasks() + }) + + expect(result.current.error).toBeTruthy() + + // Clear the error + act(() => { + result.current.clearError() + }) + + expect(result.current.error).toBeNull() + }) + }) + + describe('refresh interval', () => { + it('should set up refresh interval', async () => { + vi.useFakeTimers() + + const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }] + + mockApiClient.listTasks + .mockResolvedValueOnce(mockTasks) + .mockResolvedValueOnce(updatedTasks) + .mockResolvedValue(updatedTasks) // Handle any extra calls + + const { result, unmount } = renderHook(() => + useTasks({ refreshInterval: 1000, autoFetch: false }) + ) + + // Manual initial fetch + await act(async () => { + await result.current.fetchTasks() + }) + + expect(result.current.tasks).toHaveLength(mockTasks.length) + + const initialCallCount = mockApiClient.listTasks.mock.calls.length + + // Advance timer to trigger refresh + await act(async () => { + vi.advanceTimersByTime(1000) + await vi.runOnlyPendingTimersAsync() + }) + + // Should have made at least one more call + expect(mockApiClient.listTasks.mock.calls.length).toBeGreaterThan( + initialCallCount + ) + expect(result.current.tasks).toHaveLength(updatedTasks.length) + + unmount() + vi.useRealTimers() + }, 10000) + }) +}) diff --git a/frontend/app/hooks/useTasks.ts b/frontend/app/hooks/useTasks.ts new file mode 100644 index 0000000..740d096 --- /dev/null +++ b/frontend/app/hooks/useTasks.ts @@ -0,0 +1,161 @@ +import { useState, useCallback, useEffect } from 'react' +import type { Task, CreateTaskRequest, ApiError } from '~/types/task' +import { TaskStatus } from '~/types/task' +import { apiClient } from '~/services/api' + +interface UseTasksState { + tasks: Task[] + loading: boolean + error: string | null + lastFetch: Date | null +} + +interface UseTasksActions { + fetchTasks: () => Promise + createTask: (data: CreateTaskRequest) => Promise + refreshTasks: () => Promise + clearError: () => void + getTaskById: (id: string) => Task | undefined + filterTasksByStatus: (status: TaskStatus) => Task[] +} + +interface UseTasksOptions { + autoFetch?: boolean + refreshInterval?: number +} + +export function useTasks( + options: UseTasksOptions = {} +): UseTasksState & UseTasksActions { + const { autoFetch = true, refreshInterval } = options + + const [state, setState] = useState({ + tasks: [], + loading: false, + error: null, + lastFetch: null, + }) + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })) + }, []) + + const fetchTasks = useCallback(async () => { + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + const tasks = await apiClient.listTasks() + setState(prev => ({ + ...prev, + tasks, + loading: false, + lastFetch: new Date(), + })) + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + loading: false, + error: apiError.message, + })) + } + }, []) + + const createTask = useCallback( + async (data: CreateTaskRequest): Promise => { + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + const newTask = await apiClient.createTask(data) + + // Add the new task to the beginning of the list (most recent first) + setState(prev => ({ + ...prev, + tasks: [newTask, ...prev.tasks], + loading: false, + })) + + return newTask + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + loading: false, + error: apiError.message, + })) + return null + } + }, + [] + ) + + const refreshTasks = useCallback(async () => { + // Force refresh without showing loading state if tasks already exist + const showLoading = state.tasks.length === 0 + + if (showLoading) { + setState(prev => ({ ...prev, loading: true, error: null })) + } else { + setState(prev => ({ ...prev, error: null })) + } + + try { + const tasks = await apiClient.listTasks() + setState(prev => ({ + ...prev, + tasks, + loading: false, + lastFetch: new Date(), + })) + } catch (error) { + const apiError = error as ApiError + setState(prev => ({ + ...prev, + loading: false, + error: apiError.message, + })) + } + }, [state.tasks]) + + const getTaskById = useCallback( + (id: string): Task | undefined => { + return state.tasks.find(task => task.id === id) + }, + [state.tasks] + ) + + const filterTasksByStatus = useCallback( + (status: TaskStatus): Task[] => { + return state.tasks.filter(task => task.status === status) + }, + [state.tasks] + ) + + // Auto-fetch tasks on mount + useEffect(() => { + if (autoFetch) { + fetchTasks() + } + }, [autoFetch, fetchTasks]) + + // Set up refresh interval if specified + useEffect(() => { + if (!refreshInterval) return + + const interval = setInterval(() => { + refreshTasks() + }, refreshInterval) + + return () => clearInterval(interval) + }, [refreshInterval, refreshTasks]) + + return { + ...state, + fetchTasks, + createTask, + refreshTasks, + clearError, + getTaskById, + filterTasksByStatus, + } +} diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index f9f8bdf..c7b440f 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -6,8 +6,12 @@ import { Scripts, ScrollRestoration, } from 'react-router' +import { ThemeProvider } from '@mui/material/styles' +import { CssBaseline } from '@mui/material' import type { Route } from './+types/root' +import { theme } from './theme' +import AppLayout from './components/Layout' import './app.css' export const links: Route.LinksFunction = () => [ @@ -42,7 +46,14 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - return + return ( + + + + + + + ) } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { @@ -62,14 +73,19 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { } return ( -
-

{message}

-

{details}

- {stack && ( -
-          {stack}
-        
- )} -
+ + + +
+

{message}

+

{details}

+ {stack && ( +
+              {stack}
+            
+ )} +
+
+
) } diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx index c95397e..80dd150 100644 --- a/frontend/app/routes/home.test.tsx +++ b/frontend/app/routes/home.test.tsx @@ -3,8 +3,14 @@ import { describe, it, expect } from 'vitest' import Home from './home' describe('Home component', () => { - it('should render welcome component', () => { + it('should render task management interface', () => { render() - expect(screen.getByText(/React Router/i)).toBeInTheDocument() + expect(screen.getByText(/Tasks/i)).toBeInTheDocument() + expect( + screen.getByText(/GTD-inspired task management system/i) + ).toBeInTheDocument() + expect( + screen.getByText(/Task Management Interface Coming Soon/i) + ).toBeInTheDocument() }) }) diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 8440a09..b818ac6 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -1,13 +1,44 @@ import type { Route } from './+types/home' -import { Welcome } from '../welcome/welcome' +import { Box, Typography, Container } from '@mui/material' export function meta(_: Route.MetaArgs) { return [ - { title: 'New React Router App' }, - { name: 'description', content: 'Welcome to React Router!' }, + { title: "Captain's Log - Tasks" }, + { name: 'description', content: 'GTD-inspired task management system' }, ] } export default function Home() { - return + return ( + + + + Tasks + + + Your GTD-inspired task management system. Capture everything, see only + what matters. + + + + + Task Management Interface Coming Soon + + + The task list, task cards, and quick capture components will be + implemented in the next phase. + + + + + ) } diff --git a/frontend/app/services/api.test.ts b/frontend/app/services/api.test.ts new file mode 100644 index 0000000..3853bc1 --- /dev/null +++ b/frontend/app/services/api.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { apiClient } from './api' +import type { Task, CreateTaskRequest, UpdateTaskRequest } from '~/types/task' +import { TaskStatus } from '~/types/task' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +// Sample task data for testing +const mockTask: Task = { + id: '550e8400-e29b-41d4-a716-446655440000', + title: 'Test Task', + description: 'Test Description', + status: TaskStatus.Todo, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + completed_at: null, +} + +const mockTasks: Task[] = [ + mockTask, + { + ...mockTask, + id: '550e8400-e29b-41d4-a716-446655440001', + title: 'Another Task', + status: TaskStatus.Done, + completed_at: '2023-01-01T01:00:00Z', + }, +] + +describe('API Client', () => { + beforeEach(() => { + mockFetch.mockClear() + // Silence console.log during tests + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listTasks', () => { + it('should fetch all tasks successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockTasks), + }) + + const result = await apiClient.listTasks() + + expect(mockFetch).toHaveBeenCalledWith('/api/tasks', { + headers: { 'Content-Type': 'application/json' }, + }) + expect(result).toEqual(mockTasks) + }) + + it('should handle empty task list', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([]), + }) + + const result = await apiClient.listTasks() + + expect(result).toEqual([]) + }) + + it('should throw error on failed request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({ message: 'Server error' }), + }) + + await expect(apiClient.listTasks()).rejects.toThrow('Server error') + }) + }) + + describe('getTask', () => { + it('should fetch single task successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockTask), + }) + + const result = await apiClient.getTask(mockTask.id) + + expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, { + headers: { 'Content-Type': 'application/json' }, + }) + expect(result).toEqual(mockTask) + }) + + it('should handle task not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve({ message: 'Task not found' }), + }) + + await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow( + 'Task not found' + ) + }) + }) + + describe('createTask', () => { + it('should create task successfully', async () => { + const newTaskData: CreateTaskRequest = { + title: 'New Task', + description: 'New Description', + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + statusText: 'Created', + json: () => Promise.resolve(mockTask), + }) + + const result = await apiClient.createTask(newTaskData) + + expect(mockFetch).toHaveBeenCalledWith('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newTaskData), + }) + expect(result).toEqual(mockTask) + }) + + it('should handle validation errors', async () => { + const invalidData: CreateTaskRequest = { + title: '', + } + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: () => Promise.resolve({ message: 'Title must not be empty' }), + }) + + await expect(apiClient.createTask(invalidData)).rejects.toThrow( + 'Title must not be empty' + ) + }) + }) + + describe('updateTask', () => { + it('should update task successfully', async () => { + const updateData: UpdateTaskRequest = { + title: 'Updated Task', + status: TaskStatus.Done, + } + + const updatedTask = { ...mockTask, ...updateData } + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(updatedTask), + }) + + const result = await apiClient.updateTask(mockTask.id, updateData) + + expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }) + expect(result).toEqual(updatedTask) + }) + + it('should handle partial updates', async () => { + const updateData: UpdateTaskRequest = { + status: TaskStatus.Done, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }), + }) + + const result = await apiClient.updateTask(mockTask.id, updateData) + + expect(result.status).toBe(TaskStatus.Done) + }) + }) + + describe('deleteTask', () => { + it('should delete task successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + statusText: 'No Content', + }) + + const result = await apiClient.deleteTask(mockTask.id) + expect(result).toBeNull() + + expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }) + }) + + it('should handle delete errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve({ message: 'Task not found' }), + }) + + await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow( + 'Task not found' + ) + }) + }) + + describe('Error Handling', () => { + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + await expect(apiClient.listTasks()).rejects.toThrow('Network error') + }) + + it('should handle malformed JSON responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.reject(new Error('Invalid JSON')), + }) + + await expect(apiClient.listTasks()).rejects.toThrow( + 'HTTP 500: Internal Server Error' + ) + }) + + it('should handle responses with no error message', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: () => Promise.resolve({}), + }) + + await expect(apiClient.listTasks()).rejects.toThrow( + 'HTTP 400: Bad Request' + ) + }) + }) +}) diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts new file mode 100644 index 0000000..3c999d3 --- /dev/null +++ b/frontend/app/services/api.ts @@ -0,0 +1,113 @@ +import type { + Task, + CreateTaskRequest, + UpdateTaskRequest, + ApiError, +} from '~/types/task' + +const API_BASE_URL = '/api' + +class ApiClient { + private async fetchWrapper( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${API_BASE_URL}${endpoint}` + + const config: RequestInit = { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + } + + if (process.env.NODE_ENV === 'development') { + console.log(`API Request: ${config.method || 'GET'} ${url}`, { + body: config.body, + headers: config.headers, + }) + } + + try { + const response = await fetch(url, config) + + if (process.env.NODE_ENV === 'development') { + console.log(`API Response: ${response.status} ${response.statusText}`, { + url, + status: response.status, + }) + } + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}` + + try { + const errorData = await response.json() + errorMessage = errorData.message || errorMessage + } catch { + // If JSON parsing fails, use the default error message + } + + throw new Error(errorMessage) + } + + if (response.status === 204) { + return null as T + } + + const data = await response.json() + + if (process.env.NODE_ENV === 'development') { + console.log('API Response Data:', data) + } + + return data + } catch (error) { + const apiError: ApiError = { + message: + error instanceof Error ? error.message : 'Unknown error occurred', + status: + error instanceof Error && 'status' in error + ? (error as { status?: number }).status + : undefined, + } + + if (process.env.NODE_ENV === 'development') { + console.error('API Error:', apiError) + } + + throw apiError + } + } + + async listTasks(): Promise { + return this.fetchWrapper('/tasks') + } + + async getTask(id: string): Promise { + return this.fetchWrapper(`/tasks/${id}`) + } + + async createTask(data: CreateTaskRequest): Promise { + return this.fetchWrapper('/tasks', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + async updateTask(id: string, data: UpdateTaskRequest): Promise { + return this.fetchWrapper(`/tasks/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }) + } + + async deleteTask(id: string): Promise { + return this.fetchWrapper(`/tasks/${id}`, { + method: 'DELETE', + }) + } +} + +export const apiClient = new ApiClient() diff --git a/frontend/app/theme.ts b/frontend/app/theme.ts new file mode 100644 index 0000000..d89fee5 --- /dev/null +++ b/frontend/app/theme.ts @@ -0,0 +1,178 @@ +import { createTheme } from '@mui/material/styles' + +// Color palette constants +const colors = { + // Primary Colors + deepNavy: '#1e3a5f', // Main brand color for headers and primary actions + oceanBlue: '#2c5282', // Secondary blue for links and active states + compassGold: '#d69e2e', // Accent color for highlights and call-to-actions + + // Status Colors + chartGreen: '#48bb78', // Completed tasks and success states + sunsetCoral: '#f56565', // Urgent tasks and error states + seaFoam: '#4fd1c7', // Information and notification states + + // Neutrals + parchment: '#f7fafc', // Clean background color + fogGray: '#e2e8f0', // Subtle borders and dividers + stormGray: '#718096', // Secondary text + anchorDark: '#2d3748', // Primary text and headings +} + +declare module '@mui/material/styles' { + interface Theme { + custom: { + task: { + todo: string + done: string + backlog: string + } + } + } + + interface ThemeOptions { + custom?: { + task?: { + todo?: string + done?: string + backlog?: string + } + } + } +} + +export const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: colors.deepNavy, + light: colors.oceanBlue, + contrastText: '#ffffff', + }, + secondary: { + main: colors.compassGold, + contrastText: '#ffffff', + }, + success: { + main: colors.chartGreen, + }, + error: { + main: colors.sunsetCoral, + }, + info: { + main: colors.seaFoam, + }, + background: { + default: colors.parchment, + paper: '#ffffff', + }, + text: { + primary: colors.anchorDark, + secondary: colors.stormGray, + }, + grey: { + 100: '#edf2f7', + 200: colors.fogGray, + 300: '#cbd5e0', + 500: colors.stormGray, + 700: colors.anchorDark, + }, + }, + typography: { + fontFamily: '"Inter", ui-sans-serif, system-ui, sans-serif', + h1: { + fontSize: '2rem', + fontWeight: 700, + lineHeight: 1.2, + }, + h2: { + fontSize: '1.5rem', + fontWeight: 600, + lineHeight: 1.3, + }, + h3: { + fontSize: '1.25rem', + fontWeight: 600, + lineHeight: 1.4, + }, + body1: { + fontSize: '1rem', + lineHeight: 1.6, + }, + body2: { + fontSize: '0.875rem', + lineHeight: 1.5, + }, + }, + shape: { + borderRadius: 12, + }, + components: { + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: colors.deepNavy, + color: '#ffffff', + boxShadow: + '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + borderBottom: 'none', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundColor: '#ffffff', + borderRight: `1px solid ${colors.fogGray}`, + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + borderRadius: '0.75rem', + fontWeight: 500, + }, + contained: { + boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)', + '&:hover': { + boxShadow: + '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + }, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: '0.75rem', + boxShadow: + '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + border: `1px solid ${colors.fogGray}`, + '&:hover': { + boxShadow: + '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + borderColor: '#cbd5e0', + }, + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + borderRadius: '0.75rem', + }, + }, + }, + }, + }, + custom: { + task: { + todo: colors.oceanBlue, + done: colors.chartGreen, + backlog: colors.stormGray, + }, + }, +}) diff --git a/frontend/app/types/task.ts b/frontend/app/types/task.ts new file mode 100644 index 0000000..9641c8d --- /dev/null +++ b/frontend/app/types/task.ts @@ -0,0 +1,35 @@ +export enum TaskStatus { + Todo = 'todo', + Done = 'done', + Backlog = 'backlog', +} + +export interface Task { + id: string + title: string + description: string | null + status: TaskStatus + created_at: string + updated_at: string + completed_at: string | null +} + +export interface CreateTaskRequest { + title: string + description?: string +} + +export interface UpdateTaskRequest { + title?: string + description?: string + status?: TaskStatus +} + +export interface ApiError { + message: string + status?: number +} + +export interface TaskListResponse { + tasks: Task[] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c0adf94..c866ebf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,6 +6,10 @@ "": { "name": "frontend", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.2", + "@mui/material": "^7.3.2", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "isbot": "^5.1.27", @@ -16,7 +20,6 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@react-router/dev": "^7.7.1", - "@tailwindcss/vite": "^4.1.4", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@types/eslint__js": "^8.42.3", @@ -29,7 +32,6 @@ "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^27.0.0", "prettier": "^3.6.2", - "tailwindcss": "^4.1.4", "typescript": "^5.8.3", "typescript-eslint": "^8.44.0", "vite": "^6.3.3", @@ -92,7 +94,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -158,7 +159,6 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -247,7 +247,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -271,7 +270,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -358,7 +356,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -368,7 +365,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -402,7 +398,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.4" @@ -539,7 +534,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -549,7 +543,6 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -564,7 +557,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -583,7 +575,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -731,6 +722,167 @@ "node": ">=18" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -1432,24 +1584,10 @@ "node": ">=12" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1471,7 +1609,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1481,14 +1618,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1501,6 +1636,251 @@ "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", "license": "MIT" }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz", + "integrity": "sha512-AOyfHjyDKVPGJJFtxOlept3EYEdLoar/RvssBTWVAvDJGIE676dLi2oT/Kx+FoVXFoA/JdV7DEMq/BVWV3KHRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.2.tgz", + "integrity": "sha512-TZWazBjWXBjR6iGcNkbKklnwodcwj0SrChCNHc9BhD9rBgET22J1eFhHsEmvSvru9+opDy3umqAimQjokhfJlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz", + "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/core-downloads-tracker": "^7.3.2", + "@mui/system": "^7.3.2", + "@mui/types": "^7.4.6", + "@mui/utils": "^7.3.2", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.1.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.2.tgz", + "integrity": "sha512-ha7mFoOyZGJr75xeiO9lugS3joRROjc8tG1u4P50dH0KR7bwhHznVMcYg7MouochUy0OxooJm/OOSpJ7gKcMvg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/utils": "^7.3.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.2.tgz", + "integrity": "sha512-PkJzW+mTaek4e0nPYZ6qLnW5RGa0KN+eRTf5FA2nc7cFZTeM+qebmGibaTLrgQBy3UpcpemaqfzToBNkzuxqew==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.2.tgz", + "integrity": "sha512-9d8JEvZW+H6cVkaZ+FK56R53vkJe3HsTpcjMUtH8v1xK6Y1TjzHdZ7Jck02mGXJsE6MQGWVs3ogRHTQmS9Q/rA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/private-theming": "^7.3.2", + "@mui/styled-engine": "^7.3.2", + "@mui/types": "^7.4.6", + "@mui/utils": "^7.3.2", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", + "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz", + "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/types": "^7.4.6", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1612,6 +1992,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@react-router/dev": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.1.tgz", @@ -2058,283 +2448,6 @@ "win32" ] }, - "node_modules/@tailwindcss/node": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", - "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.5.1", - "lightningcss": "1.30.1", - "magic-string": "^0.30.18", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.13" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", - "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-x64": "4.1.13", - "@tailwindcss/oxide-freebsd-x64": "4.1.13", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-x64-musl": "4.1.13", - "@tailwindcss/oxide-wasm32-wasi": "4.1.13", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", - "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", - "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", - "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", - "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", - "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", - "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", - "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", - "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", - "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", - "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.5", - "@emnapi/runtime": "^1.4.5", - "@emnapi/wasi-threads": "^1.0.4", - "@napi-rs/wasm-runtime": "^0.2.12", - "@tybys/wasm-util": "^0.10.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", - "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", - "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.13.tgz", - "integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.13", - "@tailwindcss/oxide": "4.1.13", - "tailwindcss": "4.1.13" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2526,11 +2639,22 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2546,6 +2670,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -3089,6 +3222,21 @@ "@babel/types": "^7.23.6" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3288,7 +3436,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3391,14 +3538,13 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", + "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": ">=18" + "node": ">=6" } }, "node_modules/color-convert": { @@ -3516,6 +3662,31 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3587,7 +3758,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -3608,7 +3778,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3690,16 +3859,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", - "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -3708,6 +3867,16 @@ "license": "MIT", "peer": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3758,20 +3927,6 @@ "node": ">= 0.8" } }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -3792,6 +3947,15 @@ "dev": true, "license": "MIT" }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3891,7 +4055,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4343,6 +4506,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4565,13 +4734,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -4613,6 +4775,21 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.3.tgz", @@ -4719,7 +4896,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4767,11 +4943,16 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4865,21 +5046,10 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4939,7 +5109,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -5016,244 +5185,11 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", @@ -5285,6 +5221,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -5481,19 +5429,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -5656,6 +5591,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5750,7 +5694,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -5759,6 +5702,30 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5801,6 +5768,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -5831,6 +5804,15 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -5852,7 +5834,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5995,6 +5976,23 @@ "node": ">=10" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6148,6 +6146,22 @@ "node": ">=18" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -6186,11 +6200,30 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -6776,6 +6809,12 @@ "dev": true, "license": "MIT" }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6789,6 +6828,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -6796,54 +6847,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tailwindcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", - "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tar": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.4.tgz", - "integrity": "sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c16f93e..a0c2abe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,10 @@ "typecheck": "react-router typegen && tsc" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.2", + "@mui/material": "^7.3.2", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "isbot": "^5.1.27", @@ -26,7 +30,6 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@react-router/dev": "^7.7.1", - "@tailwindcss/vite": "^4.1.4", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@types/eslint__js": "^8.42.3", @@ -39,7 +42,6 @@ "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^27.0.0", "prettier": "^3.6.2", - "tailwindcss": "^4.1.4", "typescript": "^5.8.3", "typescript-eslint": "^8.44.0", "vite": "^6.3.3", diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index 7a4c415..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,76 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./app/**/*.{js,jsx,ts,tsx}", - ], - theme: { - extend: { - fontFamily: { - sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"], - }, - colors: { - primary: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - }, - success: { - 50: '#f0fdf4', - 100: '#dcfce7', - 200: '#bbf7d0', - 300: '#86efac', - 400: '#4ade80', - 500: '#22c55e', - 600: '#16a34a', - 700: '#15803d', - 800: '#166534', - 900: '#14532d', - }, - warning: { - 50: '#fffbeb', - 100: '#fef3c7', - 200: '#fde68a', - 300: '#fcd34d', - 400: '#fbbf24', - 500: '#f59e0b', - 600: '#d97706', - 700: '#b45309', - 800: '#92400e', - 900: '#78350f', - }, - danger: { - 50: '#fef2f2', - 100: '#fee2e2', - 200: '#fecaca', - 300: '#fca5a5', - 400: '#f87171', - 500: '#ef4444', - 600: '#dc2626', - 700: '#b91c1c', - 800: '#991b1b', - 900: '#7f1d1d', - }, - }, - spacing: { - '18': '4.5rem', - '88': '22rem', - }, - borderRadius: { - 'xl': '0.75rem', - '2xl': '1rem', - }, - boxShadow: { - 'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', - 'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', - }, - }, - }, - plugins: [], -} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e6410e2..d2e73cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,9 @@ import { reactRouter } from "@react-router/dev/vite"; -import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + plugins: [reactRouter(), tsconfigPaths()], server: { proxy: { "/api": { diff --git a/justfile b/justfile index c0e67ce..ad5f466 100644 --- a/justfile +++ b/justfile @@ -25,5 +25,7 @@ fmt-check: fmt-check-backend fmt-check-frontend lint: lint-backend lint-frontend +check: fmt-check lint test + clean: clean-backend clean-frontend diff --git a/plan/01_CORE_MVP/frontend.md b/plan/01_CORE_MVP/frontend.md index 93f956c..325f4c7 100644 --- a/plan/01_CORE_MVP/frontend.md +++ b/plan/01_CORE_MVP/frontend.md @@ -1,7 +1,7 @@ # Frontend MVP Implementation Plan ## Overview -This plan details the concrete implementation steps for the Captain's Log frontend MVP using Vite + React + TypeScript + Tailwind CSS with comprehensive testing infrastructure. +This plan details the concrete implementation steps for the Captain's Log frontend MVP using Vite + React + TypeScript with comprehensive testing infrastructure. ## Project Structure (Planned Implementation) ``` @@ -15,7 +15,7 @@ captains-log/ ├── package.json # Node.js project manifest ├── vite.config.ts # Vite configuration ├── tsconfig.json # TypeScript configuration - ├── tailwind.config.js # Tailwind CSS configuration + ├── postcss.config.js # PostCSS configuration ├── index.html # Entry HTML file ├── src/ │ ├── main.tsx # Application entry point @@ -35,7 +35,7 @@ captains-log/ │ ├── types/ │ │ └── task.ts # TypeScript type definitions │ └── styles/ - │ └── index.css # Tailwind imports and custom styles + │ └── index.css # CSS imports and custom styles └── tests/ └── components/ # Component unit tests ``` @@ -45,16 +45,16 @@ captains-log/ ### Task 1.1: Initialize Frontend Project - [x] **File**: `frontend/package.json` - Create new Vite + React + TypeScript project - - Add dependencies: react, react-dom, react-router-dom, tailwindcss, @types/* + - Add dependencies: react, react-dom, react-router-dom, @types/* - Add dev dependencies: vitest, @testing-library/react, @testing-library/jest-dom - Configure scripts for dev, build, test, lint - **Expected outcome**: `npm install` succeeds ### Task 1.2: Setup Build Tools and Configuration -- [x] **Files**: `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/tailwind.config.js` +- [x] **Files**: `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/postcss.config.js` - Configure Vite with React plugin and proxy to backend - Setup TypeScript with strict mode and path aliases - - Configure Tailwind CSS with custom design tokens + - Configure CSS styling framework - Setup Vitest for testing - **Expected outcome**: `npm run dev` starts frontend development server @@ -75,14 +75,14 @@ captains-log/ ## Phase 2: Core API Integration (Days 3-4) ### Task 2.1: Define TypeScript Types -- [ ] **File**: `frontend/src/types/task.ts` +- [x] **File**: `frontend/src/types/task.ts` - Create Task interface matching backend TaskModel - Add TaskStatus enum (Todo, Done, Backlog) - Include API response types and error types - **Expected outcome**: Type definitions compile without errors ### Task 2.2: Backend API Client -- [ ] **File**: `frontend/src/services/api.ts` +- [x] **File**: `frontend/src/services/api.ts` - Implement API client with fetch wrapper - Add all task endpoints: GET, POST, PUT, DELETE /api/tasks - Include error handling and response parsing @@ -90,7 +90,7 @@ captains-log/ - **Expected outcome**: API client can communicate with backend ### Task 2.3: Custom React Hooks for API -- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts` +- [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts` - Create useTask hook for single task operations (get, update, delete) - Create useTasks hook for task list operations (list, create) - Include loading states, error handling, and optimistic updates @@ -98,7 +98,7 @@ captains-log/ - **Expected outcome**: Hooks provide clean API for components ### Task 2.4: API Integration Tests -- [ ] **File**: `frontend/tests/api.test.ts` +- [x] **File**: `frontend/tests/api.test.ts` - Test API client with mock responses - Test custom hooks with mock API calls - Test error handling scenarios @@ -107,7 +107,26 @@ captains-log/ ## Phase 3: Core Components (Days 5-6) -### Task 3.1: Task Card Component +**UI Framework**: Use Material-UI (MUI) for consistent design system and components alongside CSS for custom styling. + +### Task 3.1: Main App Component and Routing +- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx` + - Setup React Router with basic navigation + - Create main layout with MUI AppBar/Drawer and task area + - Implement responsive design with MUI breakpoints and CSS + - Add MUI loading states (CircularProgress) and error boundaries + - Configure MUI theme with custom colors + - **Expected outcome**: Full application loads and navigates properly with Material Design + +### Task 3.2: Task List Component +- [ ] **File**: `frontend/src/components/TaskList.tsx` + - Display tasks using MUI List/Grid components + - Filter tasks by status using MUI Chip/Select components + - Sort tasks with MUI Select dropdown + - Implement virtual scrolling with MUI virtualization + - **Expected outcome**: TaskList displays tasks efficiently with Material Design + +### Task 3.3: Task Card Component - [ ] **File**: `frontend/src/components/TaskCard.tsx` - Display task with title, description, status, dates - Implement inline editing for title and description @@ -116,15 +135,16 @@ captains-log/ - Mobile-friendly touch interactions - **Expected outcome**: TaskCard displays and edits tasks correctly -### Task 3.2: Task Form Component +### Task 3.4: Task Form Component - [ ] **File**: `frontend/src/components/TaskForm.tsx` - - Create/edit form with all task properties - - Form validation and error display - - Handle form submission with API calls - - Support both modal and inline modes - - **Expected outcome**: TaskForm creates and updates tasks + - Create/edit form using Formik for form state management + - Use MUI TextField, Select, and Button components + - Form validation with Yup schema and error display + - Handle form submission with API calls through Formik onSubmit + - Support both MUI Dialog modal and inline modes + - **Expected outcome**: TaskForm creates and updates tasks with robust form handling -### Task 3.3: Quick Capture Component +### Task 3.5: Quick Capture Component - [ ] **File**: `frontend/src/components/QuickCapture.tsx` - Minimal input for rapid task creation - Auto-focus and keyboard shortcuts @@ -132,14 +152,14 @@ captains-log/ - Smart defaults for new tasks - **Expected outcome**: QuickCapture enables fast task entry -### Task 3.4: Status Badge Component +### Task 3.6: Status Badge Component - [ ] **File**: `frontend/src/components/StatusBadge.tsx` - Visual status indicators with colors - Consistent styling across components - Accessible color schemes - **Expected outcome**: StatusBadge provides clear status visualization -### Task 3.5: Component Unit Tests +### Task 3.7: Component Unit Tests - [ ] **Files**: `frontend/tests/components/*.test.tsx` - Test all components with React Testing Library - Test user interactions and state changes @@ -149,22 +169,6 @@ captains-log/ ## Phase 4: Main Application (Days 7-8) -### Task 4.1: Task List Component -- [ ] **File**: `frontend/src/components/TaskList.tsx` - - Display tasks in organized list/grid layout - - Filter tasks by status (Todo, In Progress, Done, Someday) - - Sort tasks by created date, priority, due date - - Implement virtual scrolling for performance - - **Expected outcome**: TaskList displays tasks efficiently - -### Task 4.2: Main App Component and Routing -- [ ] **Files**: `frontend/src/App.tsx`, `frontend/src/main.tsx` - - Setup React Router with basic navigation - - Create main layout with header and task area - - Implement responsive design with Tailwind - - Add loading states and error boundaries - - **Expected outcome**: Full application loads and navigates properly - ### Task 4.3: State Management and Persistence - [ ] **File**: `frontend/src/hooks/useApi.ts` - Implement localStorage for offline task caching @@ -231,7 +235,6 @@ captains-log/ "@vitejs/plugin-react": "^4.3.0", "vite": "^5.4.0", "typescript": "^5.5.0", - "tailwindcss": "^3.4.0", "vitest": "^2.0.0", "@testing-library/react": "^16.0.0", "@testing-library/jest-dom": "^6.5.0" diff --git a/plan/01_CORE_MVP/plan.md b/plan/01_CORE_MVP/plan.md index 28c1c88..9776281 100644 --- a/plan/01_CORE_MVP/plan.md +++ b/plan/01_CORE_MVP/plan.md @@ -34,7 +34,7 @@ CREATE TABLE tasks ( ); ``` -#### Frontend: Vite + React + Tailwind +#### Frontend: Vite + React - Single-page task management interface with React Router - Quick capture form with React hooks - Task list with inline editing using React components @@ -81,7 +81,7 @@ CREATE TABLE tasks ( #### Week 2: Frontend Foundation 1. **Vite + React Setup** - Initialize Vite project with React template - - Configure Tailwind CSS + - Configure CSS styling - Setup React Router for navigation - Setup API client utilities - **Testing**: Setup Vitest, React Testing Library, and Playwright