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.
-
-[](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}
+
+
+
+ }
+ onClick={resetError}
+ size="large"
+ >
+ Try Again
+
+
+
+
+
+ )
+}
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